use std::cell::{Cell, RefCell};
use crate::errors::{QlError, QlResult};
use crate::math::array::Array;
use crate::math::interpolations::Interpolator;
use crate::math::optimization::constraint::NoConstraint;
use crate::math::optimization::costfunction::CostFunction;
use crate::math::optimization::endcriteria::EndCriteria;
use crate::math::optimization::levenbergmarquardt::LevenbergMarquardt;
use crate::math::optimization::method::OptimizationMethod;
use crate::math::optimization::problem::Problem;
use crate::patterns::observable::Observable;
use crate::require;
use crate::shared::Shared;
use crate::termstructures::bootstraphelper::{BootstrapHelperShared, RateHelper};
use crate::termstructures::bootstraptraits::{BootstrapTraits, YieldBootstrapTraits};
use crate::termstructures::iterativebootstrap::{Bootstrap, PiecewiseCurve};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::date::Date;
use crate::types::{Real, Size, Time};
pub type AdditionalPenalties = dyn Fn(&[Time], &[Real]) -> Vec<Real>;
pub type AdditionalDates = dyn Fn() -> Vec<Date>;
pub trait AdditionalBootstrapVariables {
fn initialize(&self, valid_data: bool) -> QlResult<Vec<Real>>;
fn update(&self, x: &[Real]) -> QlResult<()>;
}
pub struct GlobalBootstrap {
additional_helpers: Vec<Shared<dyn RateHelper>>,
additional_dates: Option<Box<AdditionalDates>>,
accuracy: Option<Real>,
end_criteria: Option<EndCriteria>,
instrument_weights: Vec<Real>,
penalties: Option<Box<AdditionalPenalties>>,
additional_variables: Option<Box<dyn AdditionalBootstrapVariables>>,
}
impl Default for GlobalBootstrap {
fn default() -> GlobalBootstrap {
GlobalBootstrap::new(None, None, Vec::new())
}
}
impl GlobalBootstrap {
pub fn new(
accuracy: Option<Real>,
end_criteria: Option<EndCriteria>,
instrument_weights: Vec<Real>,
) -> GlobalBootstrap {
GlobalBootstrap {
additional_helpers: Vec::new(),
additional_dates: None,
accuracy,
end_criteria,
instrument_weights,
penalties: None,
additional_variables: None,
}
}
pub fn with_penalties<F>(
additional_helpers: Vec<Shared<dyn RateHelper>>,
additional_dates: Option<Box<AdditionalDates>>,
accuracy: Option<Real>,
end_criteria: Option<EndCriteria>,
instrument_weights: Vec<Real>,
penalties: F,
) -> GlobalBootstrap
where
F: Fn(&[Time], &[Real]) -> Vec<Real> + 'static,
{
GlobalBootstrap {
additional_helpers,
additional_dates,
accuracy,
end_criteria,
instrument_weights,
penalties: Some(Box::new(penalties)),
additional_variables: None,
}
}
pub fn with_grid_independent_penalties<F>(
additional_helpers: Vec<Shared<dyn RateHelper>>,
additional_dates: Option<Box<AdditionalDates>>,
accuracy: Option<Real>,
end_criteria: Option<EndCriteria>,
instrument_weights: Vec<Real>,
penalties: F,
) -> GlobalBootstrap
where
F: Fn() -> Vec<Real> + 'static,
{
Self::with_penalties(
additional_helpers,
additional_dates,
accuracy,
end_criteria,
instrument_weights,
move |_, _| penalties(),
)
}
#[must_use]
pub fn with_additional_variables(
mut self,
variables: Box<dyn AdditionalBootstrapVariables>,
) -> GlobalBootstrap {
self.additional_variables = Some(variables);
self
}
}
struct GlobalCost<'a, C: PiecewiseCurve>
where
C::Traits: YieldBootstrapTraits,
{
curve: &'a C,
alive: &'a [Shared<C::Helper>],
alive_weights: &'a [Real],
penalties: Option<&'a AdditionalPenalties>,
variables: Option<&'a dyn AdditionalBootstrapVariables>,
interior: Size,
penalty_len: Cell<Size>,
error: RefCell<Option<QlError>>,
}
impl<C: PiecewiseCurve> GlobalCost<'_, C>
where
C::Traits: YieldBootstrapTraits,
{
fn try_values(&self, x: &Array) -> QlResult<Array> {
{
let mut cd = self.curve.curve_data().borrow_mut();
for i in 0..self.interior {
let t = cd.times()[i + 1];
let value = C::Traits::transform_direct(x[i], t);
C::Traits::update_guess(cd.data_mut(), value, i + 1);
}
cd.rebuild(self.curve.interpolator(), self.interior)?;
}
if let Some(variables) = self.variables {
let trial: &[Real] = x;
variables.update(&trial[self.interior..])?;
}
let penalty_errors = match self.penalties {
Some(penalties) => {
let cd = self.curve.curve_data().borrow();
penalties(cd.times(), cd.data())
}
None => Vec::new(),
};
self.penalty_len.set(penalty_errors.len());
let mut residuals = Array::with_size(self.alive.len() + penalty_errors.len());
for (i, helper) in self.alive.iter().enumerate() {
residuals[i] = helper.quote_error()? * self.alive_weights[i];
}
for (i, penalty_error) in penalty_errors.into_iter().enumerate() {
residuals[self.alive.len() + i] = penalty_error;
}
Ok(residuals)
}
}
impl<C: PiecewiseCurve> CostFunction for GlobalCost<'_, C>
where
C::Traits: YieldBootstrapTraits,
{
fn values(&self, x: &Array) -> Array {
match self.try_values(x) {
Ok(values) => values,
Err(err) => {
let mut slot = self.error.borrow_mut();
if slot.is_none() {
*slot = Some(err);
}
let residuals = self.alive.len() + self.penalty_len.get();
std::iter::repeat_n(Real::NAN, residuals).collect()
}
}
}
}
impl<C> Bootstrap<C> for GlobalBootstrap
where
C: PiecewiseCurve<Helper = dyn RateHelper, TS = dyn YieldTermStructure>,
C::Traits: YieldBootstrapTraits,
{
fn additional_observables(&self) -> Vec<Shared<Observable>> {
self.additional_helpers
.iter()
.map(|helper| helper.base().observable_shared())
.collect()
}
fn calculate(&self, curve: &C) -> QlResult<()> {
let instruments = curve.instruments();
let n = instruments.len();
require!(
self.instrument_weights.is_empty() || self.instrument_weights.len() == n,
"GlobalBootstrap: number of instrument weights ({}) must match number of instruments ({n})",
self.instrument_weights.len()
);
let mut weights = self.instrument_weights.clone();
weights.resize(n, 1.0);
let first_date = curve.initial_date()?;
let mut alive: Vec<Shared<C::Helper>> = Vec::new();
let mut alive_weights: Vec<Real> = Vec::new();
for (helper, weight) in instruments.iter().zip(&weights) {
if helper.pillar_date() > first_date {
alive.push(Shared::clone(helper));
alive_weights.push(*weight);
}
}
let alive_additional: Vec<&Shared<dyn RateHelper>> = self
.additional_helpers
.iter()
.filter(|helper| helper.pillar_date() > first_date)
.collect();
let additional_dates: Vec<Date> = match &self.additional_dates {
Some(dates) => dates()
.into_iter()
.filter(|date| *date > first_date)
.collect(),
None => Vec::new(),
};
let mut dates = Vec::with_capacity(alive.len() + additional_dates.len() + 1);
dates.push(first_date);
dates.extend(alive.iter().map(|helper| helper.pillar_date()));
dates.extend(additional_dates);
dates.sort_unstable();
dates.dedup();
let required = curve.interpolator().required_points();
require!(
dates.len() >= required,
"GlobalBootstrap: not enough curve points ({}) for interpolation requiring at least {required}",
dates.len()
);
let mut times = Vec::with_capacity(dates.len());
for date in &dates {
times.push(curve.time_from_reference(*date)?);
}
let mut max_date = *dates.last().expect("the grid holds the first date");
for helper in alive.iter().chain(alive_additional.iter().copied()) {
max_date = max_date.max(helper.latest_relevant_date());
}
let nodes = dates.len();
let interior = nodes - 1;
let initial_value = curve.initial_value()?;
let valid_data = {
let mut cd = curve.curve_data().borrow_mut();
let reuse = cd.is_valid() && cd.data().len() == nodes;
cd.set_pillars(dates, times);
if !reuse {
cd.reset_data(initial_value, nodes);
}
cd.set_max_date(max_date);
reuse
};
let term_structure = curve.term_structure_shared()?;
for helper in &alive {
if helper.quote_value().is_err() {
crate::fail!(
"instrument (maturity: {}, pillar: {}) has an invalid quote",
helper.maturity_date(),
helper.pillar_date()
);
}
helper.set_term_structure(&term_structure);
}
for helper in &alive_additional {
if helper.quote_value().is_err() {
crate::fail!(
"additional instrument (maturity: {}) has an invalid quote",
helper.maturity_date()
);
}
helper.set_term_structure(&term_structure);
}
let additional_guesses = match &self.additional_variables {
Some(variables) => variables.initialize(valid_data)?,
None => Vec::new(),
};
let mut guess = Array::with_size(interior + additional_guesses.len());
{
let mut cd = curve.curve_data().borrow_mut();
for i in 0..interior {
let g = C::Traits::guess(i + 1, cd.times(), cd.data(), valid_data);
C::Traits::update_guess(cd.data_mut(), g, i + 1);
guess[i] = C::Traits::transform_inverse(cd.data()[i + 1], cd.times()[i + 1]);
}
cd.rebuild(curve.interpolator(), interior)?;
}
for (i, additional_guess) in additional_guesses.into_iter().enumerate() {
guess[interior + i] = additional_guess;
}
let accuracy = self.accuracy.unwrap_or_else(|| curve.accuracy());
let mut optimizer = LevenbergMarquardt::new(accuracy, accuracy, accuracy, false);
let end_criteria = match self.end_criteria {
Some(criteria) => criteria,
None => EndCriteria::new(1000, Some(10), accuracy, accuracy, Some(accuracy))?,
};
let cost = GlobalCost::<C> {
curve,
alive: &alive,
alive_weights: &alive_weights,
penalties: self.penalties.as_deref(),
variables: self.additional_variables.as_deref(),
interior,
penalty_len: Cell::new(0),
error: RefCell::new(None),
};
let no_constraint = NoConstraint;
let (end_type, solution) = {
let mut problem = Problem::new(&cost, &no_constraint, guess);
let outcome = optimizer.minimize(&mut problem, &end_criteria);
(outcome, problem.current_value().clone())
};
if let Some(inner) = cost.error.into_inner() {
return Err(inner);
}
let end_type = end_type?;
require!(
end_type.succeeded(),
"global bootstrap failed to minimize to required accuracy: {end_type}"
);
{
let mut cd = curve.curve_data().borrow_mut();
for i in 0..interior {
let t = cd.times()[i + 1];
let value = C::Traits::transform_direct(solution[i], t);
C::Traits::update_guess(cd.data_mut(), value, i + 1);
}
cd.rebuild(curve.interpolator(), interior)?;
cd.set_valid(true);
}
if let Some(variables) = &self.additional_variables {
let solved: &[Real] = &solution;
variables.update(&solved[interior..])?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
use crate::handle::Handle;
use crate::indexes::IborIndex;
use crate::indexes::ibor::euribor::Euribor;
use crate::interestrate::Compounding;
use crate::math::interpolations::flat::BackwardFlat;
use crate::math::interpolations::linear::Linear;
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::shared;
use crate::termstructures::TermStructure;
use crate::termstructures::bootstraphelper::RateHelper;
use crate::termstructures::bootstraptraits::{ForwardRate, SimpleZeroYield};
use crate::termstructures::globalbootstrapvars::SimpleQuoteVariables;
use crate::termstructures::yields::{
DepositRateHelper, FraRateHelper, PiecewiseYieldCurve, Pillar, SwapRateHelper,
};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendars::target::Target;
use crate::time::date::{Date, Day, Month, Year};
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::types::Natural;
const REF_MKT_RATE: [Real; 32] = [
-0.373, -0.388, -0.402, -0.418, -0.431, -0.441, -0.45, -0.457, -0.463, -0.469, -0.461,
-0.463, -0.479, -0.4511, -0.45418, -0.439, -0.4124, -0.37703, -0.3335, -0.28168, -0.22725,
-0.1745, -0.12425, -0.07746, 0.0385, 0.1435, 0.17525, 0.17275, 0.1515, 0.1225, 0.095,
0.0644,
];
const SWAP_TENORS: [i32; 19] = [
2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 15, 20, 25, 30, 35, 40, 45, 50,
];
const REF_DATE: [(Day, Month, Year); 32] = [
(31, Month::March, 2020),
(30, Month::April, 2020),
(29, Month::May, 2020),
(30, Month::June, 2020),
(31, Month::July, 2020),
(31, Month::August, 2020),
(30, Month::September, 2020),
(30, Month::October, 2020),
(30, Month::November, 2020),
(31, Month::December, 2020),
(29, Month::January, 2021),
(26, Month::February, 2021),
(31, Month::March, 2021),
(30, Month::September, 2021),
(30, Month::September, 2022),
(29, Month::September, 2023),
(30, Month::September, 2024),
(30, Month::September, 2025),
(30, Month::September, 2026),
(30, Month::September, 2027),
(29, Month::September, 2028),
(28, Month::September, 2029),
(30, Month::September, 2030),
(30, Month::September, 2031),
(29, Month::September, 2034),
(30, Month::September, 2039),
(30, Month::September, 2044),
(30, Month::September, 2049),
(30, Month::September, 2054),
(30, Month::September, 2059),
(30, Month::September, 2064),
(30, Month::September, 2069),
];
const REF_ZERO_RATE_NP: [Real; 32] = [
-0.00373354067173059,
-0.0038619401591129116,
-0.003952053377431906,
-0.004033031764634922,
-0.004080332294683344,
-0.00410875148971975,
-0.004119347704602793,
-0.004191606489573042,
-0.0042481675261172285,
-0.004299228525952772,
-0.004280288678277469,
-0.0042917785223669895,
-0.00434401190355896,
-0.0044524306053832785,
-0.004485055406658176,
-0.004336901365743163,
-0.004074010693284356,
-0.0037275150355157486,
-0.0033005022038937737,
-0.002791390998101853,
-0.0022547726443914143,
-0.0017342152462374019,
-0.001236880404786612,
-0.0007723647126770113,
0.0003855052397250581,
0.0014420799596420013,
0.001759470920941431,
0.00172834231444819,
0.0015075667291268061,
0.0012113127300807914,
0.0009338400348746001,
0.0006289189187075171,
];
const REF_ZERO_RATE_AD: [Real; 32] = [
-0.00373354067173059,
-0.003810050775257402,
-0.0038768926341334457,
-0.0039412379977853225,
-0.0040770590967655115,
-0.004136329462812865,
-0.004119347704602793,
-0.004163696290681206,
-0.004205570570898868,
-0.004244312604300202,
-0.004278238728862865,
-0.004309771141705333,
-0.00434401190355896,
-0.0044524306053832785,
-0.004485055406658101,
-0.0043369013657431075,
-0.0040740106932843105,
-0.0037275150355157113,
-0.003300502203893726,
-0.002791390998101797,
-0.0022547726443913644,
-0.0017342152462374019,
-0.0012368804047865718,
-0.0007723647126769745,
0.00038554381038254917,
0.0014424807165811571,
0.001759949836190311,
0.0017287285812646173,
0.0015078180913406802,
0.0012114528819535877,
0.000933912094611891,
0.0006289461592278805,
];
const REF_ZERO_RATE_GP: [Real; 32] = [
-0.0037789204343363957,
-0.003861265918257509,
-0.003947374024601186,
-0.0040291352443265075,
-0.004095413491332919,
-0.0041325177094445505,
-0.00415463322202404,
-0.004194838278258465,
-0.004242382682770642,
-0.004278749680844317,
-0.0042971214597928705,
-0.00431898196411309,
-0.004360271377797676,
-0.00445296974357845,
-0.004485023476300989,
-0.004336935907495182,
-0.0040740612083099365,
-0.0037275506595484164,
-0.003300180655052014,
-0.0027913299732067252,
-0.002254907688857512,
-0.0017342855088808304,
-0.0012364330378685168,
-0.0007729806599035981,
0.0003854725793177982,
0.001442061640980936,
0.001759475820307776,
0.0017283380850002651,
0.0015075606415153413,
0.0012113489415541431,
0.0009337950842231714,
0.0006289530535829015,
];
type PenaltyCurve = PiecewiseYieldCurve<ForwardRate, BackwardFlat, GlobalBootstrap>;
struct Fixture {
reference_date: Date,
helpers: Vec<Shared<dyn RateHelper>>,
settings: Shared<Settings<Date>>,
index: IborIndex,
}
fn fixture() -> Fixture {
let calendar = Target::new();
let today = Date::new(26, Month::September, 2019);
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
let reference_date = calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let euribor6m = Euribor::six_months(Handle::empty(), Shared::clone(&settings));
let mut helpers: Vec<Shared<dyn RateHelper>> = Vec::new();
helpers.push(
DepositRateHelper::from_rate(REF_MKT_RATE[0] / 100.0, &euribor6m)
as Shared<dyn RateHelper>,
);
for (i, rate) in REF_MKT_RATE[1..=12].iter().enumerate() {
let quote = Handle::new(shared(SimpleQuote::new(rate / 100.0)) as Shared<dyn Quote>);
helpers.push(FraRateHelper::from_months(
quote,
i as Natural + 1,
&euribor6m,
true,
Pillar::LastRelevantDate,
) as Shared<dyn RateHelper>);
}
for (i, tenor) in SWAP_TENORS.iter().enumerate() {
helpers.push(SwapRateHelper::from_rate(
REF_MKT_RATE[13 + i] / 100.0,
Period::new(*tenor, TimeUnit::Years),
calendar.clone(),
Frequency::Annual,
BusinessDayConvention::ModifiedFollowing,
Thirty360::with_convention(Convention::BondBasis),
&euribor6m,
) as Shared<dyn RateHelper>);
}
Fixture {
reference_date,
helpers,
settings,
index: euribor6m,
}
}
fn curve_with(fixture: &Fixture, bootstrap: GlobalBootstrap) -> Shared<PenaltyCurve> {
PiecewiseYieldCurve::with_bootstrap(
fixture.reference_date,
fixture.helpers.clone(),
Actual365Fixed::new(),
BackwardFlat,
bootstrap,
)
.expect("the 32-helper strip builds a curve")
}
fn zero_rates(curve: &dyn YieldTermStructure, fixture: &Fixture) -> Vec<Real> {
fixture
.helpers
.iter()
.map(|helper| {
curve
.zero_rate_date(
helper.pillar_date(),
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
false,
)
.expect("the bootstrapped curve prices every pillar")
.rate()
})
.collect()
}
#[test]
fn a_penalty_that_reprices_a_helper_solves() {
let fixture = fixture();
let probe = Shared::clone(&fixture.helpers[0]);
let fired = shared(Cell::new(0_usize));
let counter = Shared::clone(&fired);
let curve = curve_with(
&fixture,
GlobalBootstrap::with_penalties(
Vec::new(),
None,
Some(1.0e-12),
None,
Vec::new(),
move |_, _| {
counter.set(counter.get() + 1);
let error = probe
.quote_error()
.expect("the probe helper reprices off the trial curve");
vec![1.0e-8 * error]
},
),
);
let rates = zero_rates(curve.as_ref(), &fixture);
assert!(
rates.iter().all(|rate| rate.is_finite()),
"the solved curve must carry finite zero rates"
);
assert!(
fired.get() > 0,
"the curve-reading penalty was never invoked"
);
for (i, helper) in fixture.helpers.iter().enumerate() {
let error = helper
.quote_error()
.expect("every helper reprices off the solved curve");
assert!(
error.abs() < 1.0e-9,
"helper {i} does not reprice under a curve-reading penalty: {error}"
);
}
}
#[test]
fn the_no_argument_penalty_adapter_is_invoked() {
let fixture = fixture();
let fired = shared(Cell::new(0_usize));
let counter = Shared::clone(&fired);
let curve = curve_with(
&fixture,
GlobalBootstrap::with_grid_independent_penalties(
Vec::new(),
None,
Some(1.0e-12),
None,
Vec::new(),
move || {
counter.set(counter.get() + 1);
vec![0.0]
},
),
);
assert!(
zero_rates(curve.as_ref(), &fixture)
.iter()
.all(|r| r.is_finite()),
"a zero constant penalty must leave the strip solvable"
);
assert!(
fired.get() > 0,
"the no-argument penalty never reached the residual vector"
);
}
#[test]
fn the_penalty_sees_the_full_node_grid() {
let fixture = fixture();
let seen = shared(Cell::new((
0_usize,
0_usize,
Real::NAN,
Real::NAN,
Real::NAN,
)));
let record = Shared::clone(&seen);
let curve = curve_with(
&fixture,
GlobalBootstrap::with_penalties(
Vec::new(),
None,
Some(1.0e-12),
None,
Vec::new(),
move |times, data| {
record.set((times.len(), data.len(), times[0], data[0], data[1]));
Vec::new()
},
),
);
assert!(
zero_rates(curve.as_ref(), &fixture)
.iter()
.all(|r| r.is_finite())
);
let (times_len, data_len, first_time, node0, node1) = seen.get();
assert_eq!(
times_len,
fixture.helpers.len() + 1,
"the closure must see every node"
);
assert_eq!(data_len, times_len);
assert_eq!(first_time, 0.0, "node 0 is the reference-date node");
assert_eq!(node0, node1, "ForwardRate mirrors node 0 onto node 1");
}
#[test]
fn global_bootstrap_penalty_pillar_dates() {
let fixture = fixture();
assert_eq!(
fixture.reference_date,
Date::new(30, Month::September, 2019)
);
for (i, (day, month, year)) in REF_DATE.iter().enumerate() {
assert_eq!(
fixture.helpers[i].pillar_date(),
Date::new(*day, *month, *year),
"helper {i} sits on the wrong pillar"
);
}
}
#[test]
fn global_bootstrap_penalty_zero_rates() {
let fixture = fixture();
let no_penalty = zero_rates(
curve_with(
&fixture,
GlobalBootstrap::new(Some(1.0e-12), None, Vec::new()),
)
.as_ref(),
&fixture,
);
let gradient_penalty = zero_rates(
curve_with(
&fixture,
GlobalBootstrap::with_penalties(
Vec::new(),
None,
Some(1.0e-12),
None,
Vec::new(),
|times, data| {
(0..times.len() - 1)
.map(|i| 0.01 * (data[i + 1] - data[i]) / (times[i + 1] - times[i]))
.collect()
},
),
)
.as_ref(),
&fixture,
);
let separation = no_penalty
.iter()
.zip(&gradient_penalty)
.map(|(np, gp)| (np - gp).abs())
.fold(0.0, Real::max);
assert!(
separation > 1.0e-5,
"the penalty did not move the solve: the two arms agree to {separation}"
);
for (i, expected) in REF_ZERO_RATE_NP.iter().enumerate() {
assert!(
(no_penalty[i] - expected).abs() < 1.0e-6,
"no-penalty zero rate {i}: {} vs {expected}",
no_penalty[i]
);
}
for (i, expected) in REF_ZERO_RATE_GP.iter().enumerate() {
assert!(
(gradient_penalty[i] - expected).abs() < 1.0e-6,
"gradient-penalty zero rate {i}: {} vs {expected}",
gradient_penalty[i]
);
}
}
type AdditionalCurve = PiecewiseYieldCurve<SimpleZeroYield, Linear, GlobalBootstrap>;
fn additional_helpers(fixture: &Fixture) -> Vec<Shared<dyn RateHelper>> {
(0..7)
.map(|i| {
let quote = Handle::new(shared(SimpleQuote::new(-0.004)) as Shared<dyn Quote>);
FraRateHelper::from_months(
quote,
12 + i,
&fixture.index,
true,
Pillar::LastRelevantDate,
) as Shared<dyn RateHelper>
})
.collect()
}
fn additional_dates(fixture: &Fixture) -> Box<AdditionalDates> {
let settings = Shared::clone(&fixture.settings);
Box::new(move || {
let calendar = Target::new();
let today = settings
.evaluation_date()
.expect("the fixture sets an evaluation date");
let settlement = calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let mut dates: Vec<Date> = (1..=5)
.map(|i| {
calendar.advance(
settlement,
i,
TimeUnit::Months,
BusinessDayConvention::Following,
false,
)
})
.collect();
dates.insert(0, today - 1);
dates.push(today - 2);
dates
})
}
fn additional_errors(helpers: Vec<Shared<dyn RateHelper>>) -> impl Fn() -> Vec<Real> {
move || {
let implied = |i: usize| {
helpers[i]
.implied_quote()
.expect("an additional helper reprices off the trial curve")
};
let a = implied(0);
let b = implied(6);
(0..5)
.map(|k| (5.0 - k as Real) / 6.0 * a + (1.0 + k as Real) / 6.0 * b - implied(1 + k))
.collect()
}
}
fn additional_curve(fixture: &Fixture) -> Shared<AdditionalCurve> {
let helpers = additional_helpers(fixture);
PiecewiseYieldCurve::with_bootstrap(
fixture.reference_date,
fixture.helpers.clone(),
Actual365Fixed::new(),
Linear,
GlobalBootstrap::with_grid_independent_penalties(
helpers.clone(),
Some(additional_dates(fixture)),
Some(1.0e-12),
None,
Vec::new(),
additional_errors(helpers),
),
)
.expect("the 32-helper strip builds a curve")
}
#[test]
fn global_bootstrap_pillar_dates() {
let fixture = fixture();
for (i, (day, month, year)) in REF_DATE.iter().enumerate() {
assert_eq!(
fixture.helpers[i].pillar_date(),
Date::new(*day, *month, *year),
"helper {i} sits on the wrong pillar"
);
}
let expected = [
Date::new(31, Month::March, 2021),
Date::new(30, Month::April, 2021),
Date::new(31, Month::May, 2021),
Date::new(30, Month::June, 2021),
Date::new(30, Month::July, 2021),
Date::new(31, Month::August, 2021),
Date::new(30, Month::September, 2021),
];
for (i, helper) in additional_helpers(&fixture).iter().enumerate() {
assert_eq!(
helper.pillar_date(),
expected[i],
"additional helper {i} sits on the wrong pillar"
);
assert!(helper.pillar_date() > fixture.reference_date);
}
}
#[test]
fn global_bootstrap_zero_rates() {
let fixture = fixture();
let rates = zero_rates(additional_curve(&fixture).as_ref(), &fixture);
let worst = rates
.iter()
.zip(&REF_ZERO_RATE_AD)
.map(|(rate, expected)| (rate - expected).abs())
.fold(0.0, Real::max);
assert!(
worst < 1.0e-6,
"the strip parts company with the dylib by {worst}"
);
}
#[test]
fn global_bootstrap_drops_stale_additional_dates() {
let fixture = fixture();
let dates = additional_curve(&fixture)
.dates()
.expect("the solved curve exposes its nodes");
assert_eq!(
dates.len(),
38,
"reference + 32 pillars + 5 surviving dates"
);
assert_eq!(dates[0], fixture.reference_date);
for date in [
Date::new(30, Month::October, 2019),
Date::new(2, Month::December, 2019),
Date::new(30, Month::December, 2019),
Date::new(30, Month::January, 2020),
Date::new(2, Month::March, 2020),
] {
assert!(dates.contains(&date), "{date} should be a node");
}
for stale in [
Date::new(25, Month::September, 2019),
Date::new(24, Month::September, 2019),
] {
assert!(!dates.contains(&stale), "{stale} precedes the first date");
}
}
#[test]
fn global_bootstrap_zeroes_both_residual_families() {
let fixture = fixture();
let helpers = additional_helpers(&fixture);
let curve = PiecewiseYieldCurve::<SimpleZeroYield, Linear, _>::with_bootstrap(
fixture.reference_date,
fixture.helpers.clone(),
Actual365Fixed::new(),
Linear,
GlobalBootstrap::with_grid_independent_penalties(
helpers.clone(),
Some(additional_dates(&fixture)),
Some(1.0e-12),
None,
Vec::new(),
additional_errors(helpers.clone()),
),
)
.expect("the 32-helper strip builds a curve");
curve.dates().expect("the strip solves");
for (i, helper) in fixture.helpers.iter().enumerate() {
let error = helper
.quote_error()
.expect("every helper reprices off the solved curve");
assert!(error.abs() < 1.0e-9, "helper {i} does not reprice: {error}");
}
for (k, error) in additional_errors(helpers)().iter().enumerate() {
assert!(
error.abs() < 1.0e-9,
"penalty term {k} does not vanish: {error}"
);
}
}
#[test]
fn global_bootstrap_additional_dates_need_penalty_terms() {
let fixture = fixture();
let curve = PiecewiseYieldCurve::<SimpleZeroYield, Linear, _>::with_bootstrap(
fixture.reference_date,
fixture.helpers.clone(),
Actual365Fixed::new(),
Linear,
GlobalBootstrap::with_penalties(
Vec::new(),
Some(additional_dates(&fixture)),
Some(1.0e-12),
None,
Vec::new(),
|_, _| Vec::new(),
),
)
.expect("construction is lazy");
let message = curve
.dates()
.expect_err("32 residuals cannot pin 37 variables")
.to_string();
assert!(
message.contains("less functions (32) than available variables (37)"),
"unexpected failure: {message}"
);
}
#[test]
fn global_bootstrap_max_date_covers_an_additional_helper() {
let fixture = fixture();
let additional = Shared::clone(&additional_helpers(&fixture)[6]);
let curve = PiecewiseYieldCurve::<SimpleZeroYield, Linear, _>::with_bootstrap(
fixture.reference_date,
fixture.helpers[..13].to_vec(),
Actual365Fixed::new(),
Linear,
GlobalBootstrap::with_penalties(
vec![Shared::clone(&additional)],
None,
Some(1.0e-12),
None,
Vec::new(),
|_, _| Vec::new(),
),
)
.expect("the front of the strip builds a curve");
let last_pillar = fixture.helpers[12].pillar_date();
let max_date = curve.max_date();
assert_ne!(
max_date, last_pillar,
"the additional helper must push the maximum past the last pillar"
);
assert_eq!(max_date, additional.latest_relevant_date());
assert!(
curve.discount_date(max_date, false).is_ok(),
"the extended range must be queryable without extrapolation"
);
}
struct RecordingVariables {
inner: SimpleQuoteVariables,
valid_data_flags: RefCell<Vec<bool>>,
trial_lengths: RefCell<Vec<Size>>,
trial_firsts: RefCell<Vec<Real>>,
}
impl AdditionalBootstrapVariables for Shared<RecordingVariables> {
fn initialize(&self, valid_data: bool) -> QlResult<Vec<Real>> {
self.as_ref().initialize(valid_data)
}
fn update(&self, x: &[Real]) -> QlResult<()> {
self.as_ref().update(x)
}
}
impl AdditionalBootstrapVariables for RecordingVariables {
fn initialize(&self, valid_data: bool) -> QlResult<Vec<Real>> {
self.valid_data_flags.borrow_mut().push(valid_data);
self.inner.initialize(valid_data)
}
fn update(&self, x: &[Real]) -> QlResult<()> {
self.trial_lengths.borrow_mut().push(x.len());
self.trial_firsts.borrow_mut().push(x[0]);
self.inner.update(x)
}
}
#[test]
fn the_additional_variables_receive_the_guess_the_trial_tail_and_the_solution() {
let calendar = Target::new();
let today = calendar.adjust(
Date::new(15, Month::June, 2026),
BusinessDayConvention::Following,
);
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
let index = Euribor::new(Period::new(3, TimeUnit::Months), Handle::empty(), settings)
.expect("a 3M tenor is valid");
let deposit_quote = shared(SimpleQuote::new(0.04557));
let deposit = DepositRateHelper::new(
Handle::new(Shared::clone(&deposit_quote) as Shared<dyn Quote>),
&index,
) as Shared<dyn RateHelper>;
let solved = shared(SimpleQuote::new(None));
let variables = Shared::new(RecordingVariables {
inner: SimpleQuoteVariables::new(vec![Shared::clone(&solved)], vec![2.0], vec![0.0])
.expect("one guess and one bound for one quote"),
valid_data_flags: RefCell::new(Vec::new()),
trial_lengths: RefCell::new(Vec::new()),
trial_firsts: RefCell::new(Vec::new()),
});
let penalty_quote = Shared::clone(&solved);
let curve = PiecewiseYieldCurve::<SimpleZeroYield, Linear, _>::with_bootstrap(
calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
),
vec![deposit],
Actual365Fixed::new(),
Linear,
GlobalBootstrap::with_grid_independent_penalties(
Vec::new(),
None,
Some(1.0e-12),
None,
Vec::new(),
move || {
let value = penalty_quote
.value()
.expect("the variable holds a trial value");
vec![value - 0.5]
},
)
.with_additional_variables(Box::new(Shared::clone(&variables))),
)
.expect("a one-deposit strip builds a curve");
curve.data().expect("the square system solves");
let value = solved.value().expect("the variable is solved");
assert!(
(value - 0.5).abs() < 1.0e-8,
"the additional variable solved to {value}, not 0.5"
);
assert_eq!(
*variables.valid_data_flags.borrow(),
vec![false],
"the first solve is a cold start"
);
deposit_quote.set_value(0.05);
curve.data().expect("the strip re-solves on the same grid");
assert_eq!(
*variables.valid_data_flags.borrow(),
vec![false, true],
"a re-solve over an unchanged grid must warm-restart"
);
let lengths = variables.trial_lengths.borrow();
assert!(
!lengths.is_empty(),
"the trial tail never reached the variables"
);
assert!(
lengths.iter().all(|length| *length == 1),
"the argument vector was split at the wrong index: {lengths:?}"
);
let first_trial = variables.trial_firsts.borrow()[0];
assert!(
(first_trial - Real::ln(2.0)).abs() < 1.0e-15,
"the solver's first trial point is {first_trial}, not the appended guess"
);
}
}