use std::cell::RefCell;
use crate::errors::QlResult;
use crate::handle::{Handle, RelinkableHandle};
use crate::instrument::Instrument;
use crate::instruments::{Bond, BondPriceType, FixedRateBond};
use crate::patterns::observable::{AsObservable, Observable};
use crate::pricingengine::PricingEngine;
use crate::pricingengines::DiscountingBondEngine;
use crate::quotes::Quote;
use crate::settings::Settings;
use crate::shared::{Shared, SharedMut, shared, shared_mut};
use crate::termstructures::bootstraphelper::{BootstrapHelperBase, RateHelper};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::period::Period;
use crate::time::schedule::Schedule;
use crate::types::{Natural, Rate, Real};
pub struct BondHelper {
base: BootstrapHelperBase,
bond: RefCell<Bond>,
term_structure_handle: RelinkableHandle<dyn YieldTermStructure>,
price_type: BondPriceType,
}
impl BondHelper {
pub fn new(
price: Handle<dyn Quote>,
bond: Bond,
price_type: BondPriceType,
) -> QlResult<Shared<BondHelper>> {
let latest = bond
.cashflows()
.last()
.map_or_else(Date::null, |flow| flow.date());
let earliest = bond.next_cash_flow_date(None)?.unwrap_or_else(Date::null);
let term_structure_handle = RelinkableHandle::<dyn YieldTermStructure>::empty();
let engine: SharedMut<dyn PricingEngine> = shared_mut(DiscountingBondEngine::new(
term_structure_handle.handle(),
None,
bond.settings_handle(),
));
let mut bond = bond;
bond.base_mut().set_pricing_engine(engine);
let base = BootstrapHelperBase::new(price);
base.set_earliest_date(earliest);
base.set_latest_date(latest);
Ok(shared(BondHelper {
base,
bond: RefCell::new(bond),
term_structure_handle,
price_type,
}))
}
pub fn price_type(&self) -> BondPriceType {
self.price_type
}
}
impl AsObservable for BondHelper {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl RateHelper for BondHelper {
fn base(&self) -> &BootstrapHelperBase {
&self.base
}
fn implied_quote(&self) -> QlResult<Real> {
self.base.term_structure()?;
let mut bond = self.bond.borrow_mut();
bond.recalculate()?;
match self.price_type {
BondPriceType::Clean => bond.clean_price(),
BondPriceType::Dirty => bond.dirty_price(),
}
}
fn set_term_structure(&self, term_structure: &Shared<dyn YieldTermStructure>) {
self.term_structure_handle
.link_to_weak(Shared::downgrade(term_structure));
self.base.set_term_structure(term_structure);
}
}
pub struct FixedRateBondHelper;
impl FixedRateBondHelper {
#[allow(clippy::too_many_arguments)]
#[allow(clippy::new_ret_no_self)]
pub fn new(
price: Handle<dyn Quote>,
settlement_days: Natural,
face_amount: Real,
schedule: Schedule,
coupons: Vec<Rate>,
day_counter: DayCounter,
payment_convention: BusinessDayConvention,
redemption: Real,
issue_date: Option<Date>,
payment_calendar: Option<Calendar>,
ex_coupon_period: Option<Period>,
ex_coupon_calendar: Calendar,
ex_coupon_convention: BusinessDayConvention,
ex_coupon_end_of_month: bool,
price_type: BondPriceType,
settings: Shared<Settings<Date>>,
) -> QlResult<Shared<BondHelper>> {
let bond = FixedRateBond::new(
settlement_days,
face_amount,
schedule,
coupons,
day_counter,
payment_convention,
redemption,
issue_date,
payment_calendar,
ex_coupon_period,
ex_coupon_calendar,
ex_coupon_convention,
ex_coupon_end_of_month,
None,
settings,
)?;
BondHelper::new(price, bond.into_bond(), price_type)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interestrate::Compounding;
use crate::quotes::SimpleQuote;
use crate::termstructures::yields::FlatForward;
use crate::test_support::{Flag, as_observer};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::calendars::target::Target;
use crate::time::date::Month;
use crate::time::dategenerationrule::DateGeneration;
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actualactual::{ActualActual, Convention};
use crate::time::frequency::Frequency;
fn today() -> Date {
Date::new(15, Month::June, 2026)
}
fn settings_on(today: Date) -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
settings
}
fn flat_curve(reference: Date, rate: Rate) -> Shared<dyn YieldTermStructure> {
shared(FlatForward::with_rate(
reference,
rate,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>
}
fn bond_schedule() -> Schedule {
Schedule::new(
Date::new(15, Month::June, 2025),
Date::new(15, Month::June, 2030),
Period::try_from(Frequency::Semiannual).unwrap(),
Target::new(),
BusinessDayConvention::Following,
BusinessDayConvention::Following,
DateGeneration::Backward,
false,
Date::null(),
Date::null(),
)
}
fn a_helper(
price: Handle<dyn Quote>,
price_type: BondPriceType,
settings: Shared<Settings<Date>>,
) -> Shared<BondHelper> {
FixedRateBondHelper::new(
price,
3,
100.0,
bond_schedule(),
vec![0.05],
ActualActual::with_convention(Convention::ISDA),
BusinessDayConvention::Following,
100.0,
Some(Date::new(15, Month::June, 2025)),
None,
None,
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
price_type,
settings,
)
.unwrap()
}
fn independent_bond(
curve: &Shared<dyn YieldTermStructure>,
settings: Shared<Settings<Date>>,
) -> FixedRateBond {
let mut bond = FixedRateBond::new(
3,
100.0,
bond_schedule(),
vec![0.05],
ActualActual::with_convention(Convention::ISDA),
BusinessDayConvention::Following,
100.0,
Some(Date::new(15, Month::June, 2025)),
None,
None,
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
None,
Shared::clone(&settings),
)
.unwrap();
let handle = Handle::new(Shared::clone(curve));
let engine = shared_mut(DiscountingBondEngine::new(handle, None, settings))
as SharedMut<dyn PricingEngine>;
bond.bond_mut().base_mut().set_pricing_engine(engine);
bond
}
#[test]
fn implied_quote_matches_the_independent_clean_price() {
let settings = settings_on(today());
let helper = a_helper(
Handle::new(shared(SimpleQuote::new(100.0)) as Shared<dyn Quote>),
BondPriceType::Clean,
Shared::clone(&settings),
);
let curve = flat_curve(today(), 0.04);
helper.set_term_structure(&curve);
let implied = helper.implied_quote().unwrap();
let mut independent = independent_bond(&curve, settings);
let clean = independent.bond_mut().clean_price().unwrap();
assert!(
(implied - clean).abs() < 1e-10,
"implied {implied} vs clean {clean}"
);
}
#[test]
fn dirty_price_type_adds_the_accrued_amount() {
let settings = settings_on(today());
let curve = flat_curve(today(), 0.04);
let clean_helper = a_helper(
Handle::new(shared(SimpleQuote::new(100.0)) as Shared<dyn Quote>),
BondPriceType::Clean,
Shared::clone(&settings),
);
clean_helper.set_term_structure(&curve);
let clean = clean_helper.implied_quote().unwrap();
let dirty_helper = a_helper(
Handle::new(shared(SimpleQuote::new(100.0)) as Shared<dyn Quote>),
BondPriceType::Dirty,
Shared::clone(&settings),
);
dirty_helper.set_term_structure(&curve);
let dirty = dirty_helper.implied_quote().unwrap();
let independent = independent_bond(&curve, settings);
let settlement = independent.bond().settlement_date(None).unwrap();
let accrued = independent.bond().accrued_amount(Some(settlement)).unwrap();
assert!(accrued > 0.0, "the bond is mid-coupon and accruing");
assert!(
(dirty - (clean + accrued)).abs() < 1e-10,
"dirty {dirty} vs clean+accrued {}",
clean + accrued
);
}
#[test]
fn moving_the_curve_updates_the_quote_without_notifying_the_helper() {
let settings = settings_on(today());
let helper = a_helper(
Handle::new(shared(SimpleQuote::new(100.0)) as Shared<dyn Quote>),
BondPriceType::Clean,
settings,
);
let quote = shared(SimpleQuote::new(0.03));
let curve: Shared<dyn YieldTermStructure> = shared(FlatForward::new(
today(),
Handle::new(Shared::clone("e) as Shared<dyn Quote>),
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
));
helper.set_term_structure(&curve);
let before = helper.implied_quote().unwrap();
let flag = Flag::new();
helper.observable().register_observer(&as_observer(&flag));
quote.set_value(0.06);
assert!(
!Flag::is_up(&flag),
"the helper must not observe the bootstrapping curve"
);
let after = helper.implied_quote().unwrap();
assert!(
(after - before).abs() > 1e-6,
"the forced recalculation must surface the curve move without a notification"
);
}
#[test]
fn dates_follow_the_bond_cashflows() {
let settings = settings_on(today());
let helper = a_helper(
Handle::new(shared(SimpleQuote::new(100.0)) as Shared<dyn Quote>),
BondPriceType::Clean,
settings,
);
assert_eq!(
helper.latest_date(),
Date::new(17, Month::June, 2030),
"the last cash flow is the redemption rolled off the 2030-06-15 maturity"
);
assert!(
helper.latest_date() > Date::new(15, Month::June, 2030),
"the adjusted last-payment date is later than the maturity date"
);
assert!(
helper.earliest_date() > today(),
"the next cash flow after settlement is in the future"
);
assert_eq!(
helper.pillar_date(),
helper.latest_date(),
"with no pillar set, the pillar falls back to the latest date"
);
}
#[test]
fn bond_bootstrap_reprices_the_quotes() {
use crate::math::interpolations::loglinear::LogLinear;
use crate::termstructures::bootstraptraits::Discount;
use crate::termstructures::yields::PiecewiseYieldCurve;
use crate::time::timeunit::TimeUnit;
const BOND_DATA: [(i32, TimeUnit, i32, Real, Real); 5] = [
(6, TimeUnit::Months, 5, 4.75, 101.320),
(1, TimeUnit::Years, 3, 2.75, 100.590),
(2, TimeUnit::Years, 5, 5.00, 105.650),
(5, TimeUnit::Years, 11, 5.50, 113.610),
(10, TimeUnit::Years, 11, 3.75, 104.070),
];
const BOND_SETTLEMENT_DAYS: Natural = 3;
const TOLERANCE: Real = 1.0e-9;
let calendar = Target::new();
let today = calendar.adjust(
Date::new(15, Month::June, 2026),
BusinessDayConvention::Following,
);
let settings = settings_on(today);
let settlement = calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let bond_day_counter = ActualActual::with_convention(Convention::ISDA);
let schedule_for = |n: i32, unit: TimeUnit, length: i32| -> (Schedule, Date) {
let maturity =
calendar.advance(today, n, unit, BusinessDayConvention::Following, false);
let issue = calendar.advance(
maturity,
-length,
TimeUnit::Years,
BusinessDayConvention::Following,
false,
);
let schedule = Schedule::new(
issue,
maturity,
Period::try_from(Frequency::Semiannual).unwrap(),
calendar.clone(),
BusinessDayConvention::Following,
BusinessDayConvention::Following,
DateGeneration::Backward,
false,
Date::null(),
Date::null(),
);
(schedule, issue)
};
let mut helpers: Vec<Shared<dyn RateHelper>> = Vec::new();
for (n, unit, length, coupon, price) in BOND_DATA {
let (schedule, issue) = schedule_for(n, unit, length);
let quote = Handle::new(shared(SimpleQuote::new(price)) as Shared<dyn Quote>);
let helper = FixedRateBondHelper::new(
quote,
BOND_SETTLEMENT_DAYS,
100.0,
schedule,
vec![coupon / 100.0],
bond_day_counter.clone(),
BusinessDayConvention::Following,
100.0,
Some(issue),
None,
None,
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
BondPriceType::Clean,
settings.clone(),
)
.unwrap();
helpers.push(helper as Shared<dyn RateHelper>);
}
let curve = PiecewiseYieldCurve::<Discount, LogLinear>::new(
settlement,
helpers,
Actual360::new(),
LogLinear,
)
.unwrap();
let handle: Handle<dyn YieldTermStructure> =
Handle::new(Shared::clone(&curve) as Shared<dyn YieldTermStructure>);
for (n, unit, length, coupon, price) in BOND_DATA {
let (schedule, issue) = schedule_for(n, unit, length);
let mut bond = FixedRateBond::new(
BOND_SETTLEMENT_DAYS,
100.0,
schedule,
vec![coupon / 100.0],
bond_day_counter.clone(),
BusinessDayConvention::Following,
100.0,
Some(issue),
None,
None,
NullCalendar::new(),
BusinessDayConvention::Unadjusted,
false,
None,
settings.clone(),
)
.unwrap();
let engine = shared_mut(DiscountingBondEngine::new(
handle.clone(),
None,
settings.clone(),
)) as SharedMut<dyn PricingEngine>;
bond.bond_mut().base_mut().set_pricing_engine(engine);
let estimated = bond.bond_mut().clean_price().unwrap();
assert!(
(estimated - price).abs() < TOLERANCE,
"{n} {unit:?} bond: estimated {estimated} vs expected {price} (error {})",
(estimated - price).abs()
);
}
}
}