use std::collections::{HashMap, HashSet, VecDeque};
use crate::{
ad::{
adreal::{ADReal, IsReal},
tape::Tape,
},
core::{
elements::curveelement::{ADCurveElement, DiscountCurveElement},
marketdatahandling::constructedelementstore::SharedElement,
request::LegsProvider,
},
currencies::exchangeratestore::ExchangeRateStore,
indices::marketindex::MarketIndex,
instruments::cashflows::{cashflow::Cashflow, cashflowtype::CashflowType},
math::{
interpolation::interpolator::Interpolator,
solvers::{
solvertraits::{ADJacobian, ContFunc, VectorFunc},
vectornewton::VectorNewton,
},
},
quotes::quote::{BuiltInstrument, Level},
rates::{
bootstrapping::{
bootstrapdiscountpolicy::BootstrapDiscountPolicy,
curvespec::{BootstrappedCurve, CurveSpec, QuoteSelector},
resolvedcurvespec::{ResolvedCurveSpec, ResolvedInstrument},
},
compounding::Compounding,
yieldtermstructure::discounttermstructure::DiscountTermStructure,
},
time::{date::Date, daycounter::DayCounter, enums::Frequency},
utils::errors::{QSError, Result},
};
use std::{cell::RefCell, rc::Rc};
pub struct MultiCurveBootstrapper {
curve_specs: Vec<CurveSpec>,
discount_policy: BootstrapDiscountPolicy,
exchange_rate_store: ExchangeRateStore,
}
impl MultiCurveBootstrapper {
#[must_use]
pub fn new(curve_specs: Vec<CurveSpec>, discount_policy: BootstrapDiscountPolicy) -> Self {
Self {
curve_specs,
discount_policy,
exchange_rate_store: ExchangeRateStore::new(),
}
}
#[must_use]
pub fn with_exchange_rate_store(mut self, store: ExchangeRateStore) -> Self {
self.exchange_rate_store = store;
self
}
pub fn bootstrap(
&self,
selector: &impl QuoteSelector,
level: Level,
) -> Result<HashMap<MarketIndex, DiscountCurveElement>> {
let resolved = self.resolve_all(selector, level)?;
let order = Self::dependency_order(&resolved, &self.discount_policy)?;
let mut bootstrapped: HashMap<MarketIndex, BootstrappedCurve> = HashMap::new();
for index in &order {
let spec = resolved.get(index).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing resolved spec for {index}"))
})?;
let curve = self.bootstrap_curve(index, spec, &bootstrapped)?;
bootstrapped.insert(index.clone(), curve);
}
Self::build_curve_elements(&resolved, &bootstrapped)
}
fn resolve_all(
&self,
selector: &impl QuoteSelector,
level: Level,
) -> Result<HashMap<MarketIndex, ResolvedCurveSpec>> {
let mut map = HashMap::new();
for spec in &self.curve_specs {
let resolved = spec.resolve(selector, level)?;
map.insert(spec.market_index().clone(), resolved);
}
Ok(map)
}
fn dependency_order(
resolved: &HashMap<MarketIndex, ResolvedCurveSpec>,
policy: &BootstrapDiscountPolicy,
) -> Result<Vec<MarketIndex>> {
let mut dep_map: HashMap<MarketIndex, HashSet<MarketIndex>> = HashMap::new();
for (idx, spec) in resolved {
let mut deps = spec.dependencies(policy);
deps.remove(idx);
for dep in &deps {
if !resolved.contains_key(dep) {
return Err(QSError::NotFoundErr(format!(
"Curve {idx} depends on {dep}, but it is not configured"
)));
}
}
dep_map.insert(idx.clone(), deps);
}
let mut indegree: HashMap<MarketIndex, usize> = resolved
.keys()
.map(|k| (k.clone(), dep_map.get(k).map_or(0, HashSet::len)))
.collect();
let mut reverse: HashMap<MarketIndex, Vec<MarketIndex>> = HashMap::new();
for (idx, deps) in &dep_map {
for dep in deps {
reverse.entry(dep.clone()).or_default().push(idx.clone());
}
}
let mut queue: VecDeque<MarketIndex> = indegree
.iter()
.filter(|(_, d)| **d == 0)
.map(|(k, _)| k.clone())
.collect();
let mut order = Vec::new();
while let Some(node) = queue.pop_front() {
order.push(node.clone());
if let Some(children) = reverse.get(&node) {
for child in children {
if let Some(v) = indegree.get_mut(child) {
*v = v.saturating_sub(1);
if *v == 0 {
queue.push_back(child.clone());
}
}
}
}
}
if order.len() < resolved.len() {
return Err(QSError::InvalidValueErr(
"Circular dependency detected among curve specifications".into(),
));
}
Ok(order)
}
fn bootstrap_curve(
&self,
target_index: &MarketIndex,
spec: &ResolvedCurveSpec,
other_curves: &HashMap<MarketIndex, BootstrappedCurve>,
) -> Result<BootstrappedCurve> {
Tape::start_recording();
let reference_date = spec.reference_date();
let dc = spec.day_counter();
let interp = spec.interpolator();
let mut times = vec![0.0_f64];
for instr in spec.instruments() {
let t = dc.year_fraction(reference_date, instr.pillar_date());
times.push(t);
}
let n = spec.instruments().len();
let x0: Vec<ADReal> = vec![ADReal::new(0.99); n];
let problem = BootstrapProblem {
target_index: target_index.clone(),
reference_date,
times: times.clone(),
day_counter: dc,
interpolator: interp,
instruments: spec.instruments(),
other_curves,
discount_policy: &self.discount_policy,
exchange_rate_store: &self.exchange_rate_store,
};
let solver = VectorNewton::new(1e-12, 200);
let solution = solver.solve(&problem, &x0)?;
let converged_x = &solution.x;
let j_matrix = problem.jacobian_ad(converged_x)?;
let trial = problem.trial_curve(converged_x);
let g_diag = problem.quote_derivatives(&trial)?;
let ift_sens = Self::solve_ift(&j_matrix, &g_diag)?;
let quote_values = spec.quote_values(); let mut corrected_dfs = Vec::with_capacity(n);
for i in 0..n {
let base = converged_x[i].value();
let mut df = ADReal::new(base);
for j in 0..n {
let dq: ADReal = (quote_values[j] - ADReal::new(quote_values[j].value())).into();
df = (df + dq * ift_sens[i][j]).into();
}
corrected_dfs.push(df);
}
let mut dfs = vec![ADReal::one()];
dfs.extend(corrected_dfs);
Ok(BootstrappedCurve::new_with_dfs(
reference_date,
times,
dfs,
dc,
interp,
))
}
fn solve_ift(j: &[Vec<f64>], g_diag: &[f64]) -> Result<Vec<Vec<f64>>> {
let n = g_diag.len();
let mut s = vec![vec![0.0; n]; n];
for j_col in 0..n {
let mut rhs: Vec<f64> = vec![0.0; n];
rhs[j_col] = -g_diag[j_col];
let col = Self::solve_f64_system(j, &rhs)?;
for i in 0..n {
s[i][j_col] = col[i];
}
}
Ok(s)
}
#[allow(clippy::needless_range_loop)]
fn solve_f64_system(a: &[Vec<f64>], b: &[f64]) -> Result<Vec<f64>> {
let n = a.len();
let mut aa: Vec<Vec<f64>> = a.to_vec();
let mut bb: Vec<f64> = b.to_vec();
for i in 0..n {
let mut pivot = i;
let mut max_val = aa[i][i].abs();
for r in (i + 1)..n {
if aa[r][i].abs() > max_val {
max_val = aa[r][i].abs();
pivot = r;
}
}
if max_val < 1e-14 {
return Err(QSError::SolverErr("Singular Jacobian in IFT".into()));
}
if pivot != i {
aa.swap(i, pivot);
bb.swap(i, pivot);
}
let diag = aa[i][i];
for c in i..n {
aa[i][c] /= diag;
}
bb[i] /= diag;
for r in 0..n {
if r == i {
continue;
}
let factor = aa[r][i];
if factor == 0.0 {
continue;
}
let bi = bb[i];
for c in i..n {
aa[r][c] -= factor * aa[i][c];
}
bb[r] -= bi * factor;
}
}
Ok(bb)
}
fn build_curve_elements(
resolved: &HashMap<MarketIndex, ResolvedCurveSpec>,
bootstrapped: &HashMap<MarketIndex, BootstrappedCurve>,
) -> Result<HashMap<MarketIndex, DiscountCurveElement>> {
let mut map = HashMap::new();
for (idx, spec) in resolved {
let bc = bootstrapped.get(idx).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing bootstrapped curve for {idx}"))
})?;
let reference_date = bc.reference_date();
let dc = spec.day_counter();
let interp = spec.interpolator();
let mut dates = vec![reference_date];
dates.extend(spec.pillar_dates());
let dfs = bc.discount_factors().to_vec();
let labels = spec.pillar_labels();
let quote_values = spec.quote_values();
let ts = DiscountTermStructure::<ADReal>::new(
dates,
dfs,
dc,
interp,
spec.enable_extrapolation(),
)?
.with_pillar_values(quote_values)?
.with_pillar_labels(labels)?;
let shared: SharedElement<dyn ADCurveElement> = Rc::new(RefCell::new(ts));
let elem = DiscountCurveElement::new(idx.clone(), spec.currency(), shared);
map.insert(idx.clone(), elem);
}
Ok(map)
}
}
struct BootstrapProblem<'a> {
target_index: MarketIndex,
reference_date: Date,
times: Vec<f64>,
day_counter: DayCounter,
interpolator: Interpolator,
instruments: &'a [ResolvedInstrument],
other_curves: &'a HashMap<MarketIndex, BootstrappedCurve>,
discount_policy: &'a BootstrapDiscountPolicy,
exchange_rate_store: &'a ExchangeRateStore,
}
impl BootstrapProblem<'_> {
fn trial_curve(&self, x: &[ADReal]) -> BootstrappedCurve {
let mut dfs = Vec::with_capacity(self.times.len());
dfs.push(ADReal::one()); dfs.extend_from_slice(x);
BootstrappedCurve::new_with_dfs(
self.reference_date,
self.times.clone(),
dfs,
self.day_counter,
self.interpolator,
)
}
fn get_curve<'b>(
&'b self,
index: &MarketIndex,
trial: &'b BootstrappedCurve,
) -> Option<&'b BootstrappedCurve> {
if index == &self.target_index {
Some(trial)
} else {
self.other_curves.get(index)
}
}
fn compute_residual(
&self,
instr: &ResolvedInstrument,
trial: &BootstrappedCurve,
) -> Result<ADReal> {
match instr.built() {
BuiltInstrument::FixedRateDeposit(dep) => {
self.residual_deposit(dep.legs(), &self.target_index, trial)
}
BuiltInstrument::Swap(swap) => self.residual_legs(swap.legs(), trial),
BuiltInstrument::BasisSwap(bs) => self.residual_legs(bs.legs(), trial),
BuiltInstrument::CrossCurrencySwap(xccy) => self.residual_xccy(xccy, trial),
BuiltInstrument::RateFutures(f) => self.residual_futures(f, instr.quote_value(), trial),
BuiltInstrument::FxForward(fx) => {
self.residual_fx_forward(fx, instr.quote_value(), trial)
}
_ => Err(QSError::InvalidValueErr(
"Unsupported instrument in bootstrap".into(),
)),
}
}
fn quote_derivatives(&self, trial: &BootstrappedCurve) -> Result<Vec<f64>> {
let mut g = Vec::with_capacity(self.instruments.len());
for instr in self.instruments {
let d = match instr.built() {
BuiltInstrument::FixedRateDeposit(dep) => {
self.annuity_fixed_coupons(dep.legs(), trial)?
}
BuiltInstrument::Swap(swap) => self.annuity_fixed_coupons(swap.legs(), trial)?,
BuiltInstrument::BasisSwap(bs) => {
self.annuity_floating_coupons(bs.legs(), trial)?
}
BuiltInstrument::CrossCurrencySwap(xccy) => {
self.annuity_fixed_coupons(xccy.legs(), trial)?
}
BuiltInstrument::RateFutures(_) => {
-1.0
}
BuiltInstrument::FxForward(fx) => {
let quote_disc_idx = self
.discount_policy
.discount_index_for_currency(fx.quote_currency());
let q_curve = self.get_curve("e_disc_idx, trial).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing quote curve {quote_disc_idx}"))
})?;
let df_q = q_curve.discount_factor(fx.delivery_date())?.value();
if fx.has_forward_points() {
let spot = self
.exchange_rate_store
.get_exchange_rate(fx.base_currency(), fx.quote_currency())
.map_or(1.0, |r| r.value());
-df_q / spot
} else {
-df_q
}
}
_ => {
return Err(QSError::InvalidValueErr(
"Unsupported instrument in quote_derivatives".into(),
))
}
};
g.push(d);
}
Ok(g)
}
fn annuity_fixed_coupons(
&self,
legs: &[crate::instruments::cashflows::leg::Leg],
trial: &BootstrappedCurve,
) -> Result<f64> {
let disc_index = self.discount_policy.csa_index();
let disc = self.get_curve(disc_index, trial).unwrap_or(trial);
let mut annuity = 0.0;
for leg in legs {
let side = leg.side().sign();
for cf in leg.cashflows() {
if let CashflowType::FixedRateCoupon(c) = cf {
let yf = c
.rate()
.day_counter()
.year_fraction(c.accrual_start_date(), c.accrual_end_date());
let df = disc.discount_factor(c.payment_date())?.value();
annuity += side * c.notional() * yf * df;
}
}
}
Ok(annuity)
}
fn annuity_floating_coupons(
&self,
legs: &[crate::instruments::cashflows::leg::Leg],
trial: &BootstrappedCurve,
) -> Result<f64> {
let disc_index = self.discount_policy.csa_index();
let disc = self.get_curve(disc_index, trial).unwrap_or(trial);
let mut annuity = 0.0;
for leg in legs {
let side = leg.side().sign();
for cf in leg.cashflows() {
if let CashflowType::FloatingRateCoupon(c) = cf {
let yf = c
.day_counter()
.year_fraction(c.accrual_start_date(), c.accrual_end_date());
let df = disc.discount_factor(c.payment_date())?.value();
annuity += side * 1.0 * yf * df; }
}
}
Ok(annuity)
}
fn residual_deposit(
&self,
legs: &[crate::instruments::cashflows::leg::Leg],
disc_index: &MarketIndex,
trial: &BootstrappedCurve,
) -> Result<ADReal> {
let disc_curve = self
.get_curve(disc_index, trial)
.ok_or_else(|| QSError::NotFoundErr(format!("Missing discount curve {disc_index}")))?;
let mut npv = ADReal::new(0.0);
for leg in legs {
let side = leg.side().sign();
npv = (npv + self.pv_leg(leg, disc_curve, trial)? * side).into();
}
Ok(npv)
}
fn residual_legs(
&self,
legs: &[crate::instruments::cashflows::leg::Leg],
trial: &BootstrappedCurve,
) -> Result<ADReal> {
let disc_index = self.discount_policy.csa_index();
let disc_curve = self
.get_curve(disc_index, trial)
.ok_or_else(|| QSError::NotFoundErr(format!("Missing discount curve {disc_index}")))?;
let mut npv = ADReal::new(0.0);
for leg in legs {
let side = leg.side().sign();
npv = (npv + self.pv_leg(leg, disc_curve, trial)? * side).into();
}
Ok(npv)
}
fn residual_xccy(
&self,
xccy: &crate::instruments::rates::crosscurrencyswap::CrossCurrencySwap,
trial: &BootstrappedCurve,
) -> Result<ADReal> {
let dom_disc_idx = self
.discount_policy
.discount_index_for_currency(xccy.domestic_currency());
let for_disc_idx = self
.discount_policy
.discount_index_for_currency(xccy.foreign_currency());
let dom_disc = self.get_curve(&dom_disc_idx, trial).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing domestic discount curve {dom_disc_idx}"))
})?;
let for_disc = self.get_curve(&for_disc_idx, trial).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing foreign discount curve {for_disc_idx}"))
})?;
let legs = xccy.legs();
let mut npv = ADReal::new(0.0);
if legs.len() >= 2 {
let dom_side = legs[0].side().sign();
npv = (npv + self.pv_leg(&legs[0], dom_disc, trial)? * dom_side).into();
let for_side = legs[1].side().sign();
npv = (npv + self.pv_leg(&legs[1], for_disc, trial)? * for_side).into();
}
Ok(npv)
}
fn residual_futures(
&self,
f: &crate::instruments::rates::ratefutures::RateFutures,
quote_value: &ADReal,
trial: &BootstrappedCurve,
) -> Result<ADReal> {
let proj_idx = f.market_index();
let proj_curve = self
.get_curve(&proj_idx, trial)
.ok_or_else(|| QSError::NotFoundErr(format!("Missing projection curve {proj_idx}")))?;
let rd = f.rate_definition();
let implied = proj_curve.forward_rate(
f.start_date(),
f.end_date(),
rd.compounding(),
rd.frequency(),
)?;
Ok((implied - *quote_value).into())
}
fn residual_fx_forward(
&self,
fx: &crate::instruments::fx::fxforward::FxForward,
quote_value: &ADReal,
trial: &BootstrappedCurve,
) -> Result<ADReal> {
let base_disc_idx = self
.discount_policy
.discount_index_for_currency(fx.base_currency());
let quote_disc_idx = self
.discount_policy
.discount_index_for_currency(fx.quote_currency());
let base_curve = self.get_curve(&base_disc_idx, trial).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing base currency curve {base_disc_idx}"))
})?;
let quote_curve = self.get_curve("e_disc_idx, trial).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing quote currency curve {quote_disc_idx}"))
})?;
let t = fx.delivery_date();
let df_base = base_curve.discount_factor(t)?;
let df_quote = quote_curve.discount_factor(t)?;
if fx.has_forward_points() {
let pts = *quote_value;
let s = self
.exchange_rate_store
.get_exchange_rate(fx.base_currency(), fx.quote_currency())
.map_err(|_| {
QSError::NotFoundErr(format!(
"Missing FX spot for {}/{}",
fx.base_currency(),
fx.quote_currency()
))
})?;
Ok((df_base - df_quote * (ADReal::one() + pts / s)).into())
} else {
let f = *quote_value;
Ok((df_base - f * df_quote).into())
}
}
fn pv_leg(
&self,
leg: &crate::instruments::cashflows::leg::Leg,
discount_curve: &BootstrappedCurve,
trial: &BootstrappedCurve,
) -> Result<ADReal> {
let proj_index = leg.market_index().unwrap_or(&self.target_index);
let proj_curve = self.get_curve(proj_index, trial).ok_or_else(|| {
QSError::NotFoundErr(format!("Missing projection curve {proj_index}"))
})?;
let mut pv = ADReal::new(0.0);
for cf in leg.cashflows() {
let (amount, pay_date) = self.cashflow_amount(cf, proj_curve)?;
let df = discount_curve.discount_factor(pay_date)?;
pv = (pv + amount * df).into();
}
Ok(pv)
}
#[allow(clippy::unused_self)]
fn cashflow_amount(
&self,
cf: &CashflowType,
proj_curve: &BootstrappedCurve,
) -> Result<(ADReal, Date)> {
match cf {
CashflowType::FixedRateCoupon(c) => {
let amt = c.amount()?;
Ok((amt, c.payment_date()))
}
CashflowType::FloatingRateCoupon(c) => {
let fwd = proj_curve.forward_rate(
c.accrual_start_date(),
c.accrual_end_date(),
Compounding::Simple,
Frequency::Annual,
)?;
c.set_fixing(fwd);
let amt = c.amount()?;
Ok((amt, c.payment_date()))
}
CashflowType::Redemption(c) => {
let amt = ADReal::new(c.amount()?);
Ok((amt, c.payment_date()))
}
CashflowType::Disbursement(c) => {
let amt = ADReal::new(-c.amount()?);
Ok((amt, c.payment_date()))
}
CashflowType::OptionEmbeddedCoupon(_) => Err(QSError::InvalidValueErr(
"Option-embedded coupons are not supported in bootstrapping".into(),
)),
}
}
}
impl ContFunc<[ADReal], Vec<ADReal>> for BootstrapProblem<'_> {
fn call(&self, x: &[ADReal]) -> Result<Vec<ADReal>> {
let trial = self.trial_curve(x);
let mut residuals = Vec::with_capacity(self.instruments.len());
for instr in self.instruments {
residuals.push(self.compute_residual(instr, &trial)?);
}
Ok(residuals)
}
}
impl VectorFunc<ADReal, ADReal> for BootstrapProblem<'_> {}
impl ADJacobian for BootstrapProblem<'_> {}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use crate::{
ad::adreal::{ADReal, IsReal},
currencies::currency::Currency,
indices::marketindex::MarketIndex,
math::interpolation::interpolator::Interpolator,
quotes::quote::{Level, Quote, QuoteDetails, QuoteLevels},
rates::{
bootstrapping::{
bootstrap::MultiCurveBootstrapper,
bootstrapdiscountpolicy::BootstrapDiscountPolicy,
curvespec::{BootstrappedCurve, CurveSpec, QuoteSelector},
},
compounding::Compounding,
},
time::{date::Date, daycounter::DayCounter, enums::Frequency, period::Period},
};
struct MapSelector {
reference_date: Date,
quotes: HashMap<String, f64>,
}
impl MapSelector {
fn new(reference_date: Date) -> Self {
Self {
reference_date,
quotes: HashMap::new(),
}
}
fn add(&mut self, id: &str, rate: f64) {
self.quotes.insert(id.to_string(), rate);
}
}
impl QuoteSelector for MapSelector {
fn select(&self, identifier: &str) -> Option<Quote> {
let rate = self.quotes.get(identifier)?;
let det: QuoteDetails = identifier.parse().ok()?;
let q = Quote::new(det, QuoteLevels::with_mid(*rate));
if q.build_instrument(self.reference_date, Level::Mid).is_ok() {
Some(q)
} else {
None
}
}
fn reference_date(&self) -> Date {
Date::new(2024, 1, 2)
}
}
fn ref_date() -> Date {
Date::new(2024, 1, 2)
}
#[test]
fn discount_policy_default_csa() {
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
assert_eq!(*policy.csa_index(), MarketIndex::SOFR);
assert_eq!(policy.csa_currency(), Currency::USD);
assert_eq!(
policy.discount_index_for_currency(Currency::USD),
MarketIndex::SOFR
);
}
#[test]
fn discount_policy_collateral_override() {
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD)
.with_collateral_curve(
Currency::CLP,
MarketIndex::Collateral(Currency::CLP, Currency::USD),
);
assert_eq!(
policy.discount_index_for_currency(Currency::CLP),
MarketIndex::Collateral(Currency::CLP, Currency::USD)
);
assert_eq!(
policy.discount_index_for_currency(Currency::USD),
MarketIndex::SOFR
);
}
#[test]
fn discount_policy_all_indices() {
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD)
.with_collateral_curve(
Currency::CLP,
MarketIndex::Collateral(Currency::CLP, Currency::USD),
);
let indices = policy.all_discount_indices();
assert!(indices.contains(&MarketIndex::SOFR));
assert!(indices.contains(&MarketIndex::Collateral(Currency::CLP, Currency::USD)));
}
#[test]
fn collateral_market_index_display_and_parse() {
let idx = MarketIndex::Collateral(Currency::CLP, Currency::USD);
let s = idx.to_string();
assert_eq!(s, "Collateral(CLP/USD)");
let parsed: MarketIndex = s.parse().unwrap();
assert_eq!(parsed, idx);
}
#[test]
fn collateral_market_index_hash_eq() {
use std::collections::HashSet;
let a = MarketIndex::Collateral(Currency::CLP, Currency::USD);
let b = MarketIndex::Collateral(Currency::CLP, Currency::USD);
let c = MarketIndex::Collateral(Currency::EUR, Currency::USD);
assert_eq!(a, b);
assert_ne!(a, c);
let mut set = HashSet::new();
set.insert(a.clone());
set.insert(b.clone());
assert_eq!(set.len(), 1);
set.insert(c);
assert_eq!(set.len(), 2);
}
#[test]
fn bootstrapped_curve_discount_factor_at_reference_date_is_one() {
let rd = ref_date();
let times = vec![0.0, 0.5, 1.0];
let dfs = vec![ADReal::new(1.0), ADReal::new(0.98), ADReal::new(0.96)];
let curve = BootstrappedCurve::new_with_dfs(
rd,
times,
dfs,
DayCounter::Actual360,
Interpolator::LogLinear,
);
let df = curve.discount_factor(rd).unwrap();
assert!((df.value() - 1.0).abs() < 1e-12);
}
#[test]
fn bootstrapped_curve_interpolates_between_pillars() {
let rd = ref_date();
let times = vec![0.0, 1.0, 2.0];
let dfs = vec![ADReal::new(1.0), ADReal::new(0.96), ADReal::new(0.92)];
let curve = BootstrappedCurve::new_with_dfs(
rd,
times,
dfs,
DayCounter::Actual365,
Interpolator::Linear,
);
let mid_date = rd + Period::from_str("6M").unwrap();
let df = curve.discount_factor(mid_date).unwrap();
assert!(df.value() > 0.92 && df.value() < 1.0);
}
#[test]
fn bootstrapped_curve_forward_rate_is_consistent() {
let rd = ref_date();
let times = vec![0.0, 0.5, 1.0];
let dfs = vec![ADReal::new(1.0), ADReal::new(0.975), ADReal::new(0.95)];
let curve = BootstrappedCurve::new_with_dfs(
rd,
times.clone(),
dfs.clone(),
DayCounter::Actual360,
Interpolator::LogLinear,
);
let end_date = rd + Period::from_str("1Y").unwrap();
let fwd = curve
.forward_rate(rd, end_date, Compounding::Simple, Frequency::Annual)
.unwrap();
assert!(fwd.value() > 0.0 && fwd.value() < 0.2);
}
#[test]
fn bootstrapper_single_curve_deposits_only() {
let rd = ref_date();
let mut selector = MapSelector::new(rd);
selector.add("FixedRateDeposit_USD_SOFR_3M", 0.05);
selector.add("FixedRateDeposit_USD_SOFR_6M", 0.051);
let spec = CurveSpec::new(
MarketIndex::SOFR,
Currency::USD,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec![
"FixedRateDeposit_USD_SOFR_3M".into(),
"FixedRateDeposit_USD_SOFR_6M".into(),
],
);
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let bootstrapper = MultiCurveBootstrapper::new(vec![spec], policy);
let result = bootstrapper.bootstrap(&selector, Level::Mid);
assert!(result.is_ok(), "Bootstrap failed: {:?}", result.err());
let curves = result.unwrap();
assert!(curves.contains_key(&MarketIndex::SOFR));
let elem = &curves[&MarketIndex::SOFR];
let curve = elem.curve();
let df0 = curve.discount_factor(rd).unwrap();
assert!(
(df0.value() - 1.0).abs() < 1e-8,
"DF(t0) = {} (expected 1.0)",
df0.value()
);
let df_3m = curve
.discount_factor(rd + Period::from_str("3M").unwrap())
.unwrap();
assert!(
df_3m.value() < 1.0 && df_3m.value() > 0.95,
"DF(3M) = {} (expected ~0.987)",
df_3m.value()
);
}
#[test]
fn bootstrapper_deposits_and_swaps() {
let rd = ref_date();
let mut selector = MapSelector::new(rd);
selector.add("FixedRateDeposit_USD_SOFR_3M", 0.05);
selector.add("FixedRateDeposit_USD_SOFR_6M", 0.051);
selector.add("OIS_USD_SOFR_1Y", 0.048);
selector.add("OIS_USD_SOFR_2Y", 0.045);
let spec = CurveSpec::new(
MarketIndex::SOFR,
Currency::USD,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec![
"FixedRateDeposit_USD_SOFR_3M".into(),
"FixedRateDeposit_USD_SOFR_6M".into(),
"OIS_USD_SOFR_1Y".into(),
"OIS_USD_SOFR_2Y".into(),
],
);
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let bootstrapper = MultiCurveBootstrapper::new(vec![spec], policy);
let result = bootstrapper.bootstrap(&selector, Level::Mid);
assert!(result.is_ok(), "Bootstrap failed: {:?}", result.err());
let curves = result.unwrap();
let elem = &curves[&MarketIndex::SOFR];
let curve = elem.curve();
let df_3m = curve
.discount_factor(rd + Period::from_str("3M").unwrap())
.unwrap()
.value();
let df_6m = curve
.discount_factor(rd + Period::from_str("6M").unwrap())
.unwrap()
.value();
let df_1y = curve
.discount_factor(rd + Period::from_str("1Y").unwrap())
.unwrap()
.value();
let df_2y = curve
.discount_factor(rd + Period::from_str("2Y").unwrap())
.unwrap()
.value();
assert!(
df_3m > df_6m && df_6m > df_1y && df_1y > df_2y,
"DFs not decreasing: 3M={df_3m}, 6M={df_6m}, 1Y={df_1y}, 2Y={df_2y}"
);
let fwd_3m = curve
.forward_rate(
rd,
rd + Period::from_str("3M").unwrap(),
Compounding::Simple,
Frequency::Annual,
)
.unwrap()
.value();
assert!(
(fwd_3m - 0.05).abs() < 0.005,
"Forward rate 0→3M = {fwd_3m} (expected ~0.05)"
);
}
#[test]
fn bootstrapper_detects_missing_dependency() {
let rd = ref_date();
let mut selector = MapSelector::new(rd);
selector.add("FixedRateDeposit_CLP_ICP_3M", 0.05);
let spec = CurveSpec::new(
MarketIndex::ICP,
Currency::CLP,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec!["FixedRateDeposit_CLP_ICP_3M".into()],
);
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let bootstrapper = MultiCurveBootstrapper::new(vec![spec], policy);
let result = bootstrapper.bootstrap(&selector, Level::Mid);
assert!(
result.is_ok(),
"Deposit-only curve should self-discount and not require CSA: {:?}",
result.err()
);
}
#[test]
fn curve_elements_expose_market_inputs_and_keep_ad_links() {
let rd = ref_date();
let mut selector = MapSelector::new(rd);
selector.add("FixedRateDeposit_USD_SOFR_3M", 0.05);
let spec = CurveSpec::new(
MarketIndex::SOFR,
Currency::USD,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec!["FixedRateDeposit_USD_SOFR_3M".into()],
);
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let bootstrapper = MultiCurveBootstrapper::new(vec![spec], policy);
let curves = bootstrapper.bootstrap(&selector, Level::Mid).unwrap();
let elem = &curves[&MarketIndex::SOFR];
assert_eq!(*elem.market_index(), MarketIndex::SOFR);
assert_eq!(elem.currency(), Currency::USD);
let curve = elem.curve();
let labels = curve.pillar_labels();
assert!(labels.is_some(), "Pillar labels should be set");
}
#[test]
fn bootstrapper_bootstraps_sofr_icp_and_collateral_curves_together() {
let rd = ref_date();
let mut selector = MapSelector::new(rd);
selector.add("FixedRateDeposit_USD_SOFR_3M", 0.05);
selector.add("FixedRateDeposit_USD_SOFR_6M", 0.051);
selector.add("FixedRateDeposit_CLP_ICP_3M", 0.06);
selector.add("FixedRateDeposit_CLP_ICP_6M", 0.062);
let sofr_spec = CurveSpec::new(
MarketIndex::SOFR,
Currency::USD,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec![
"FixedRateDeposit_USD_SOFR_3M".into(),
"FixedRateDeposit_USD_SOFR_6M".into(),
],
);
let icp_spec = CurveSpec::new(
MarketIndex::ICP,
Currency::CLP,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec![
"FixedRateDeposit_CLP_ICP_3M".into(),
"FixedRateDeposit_CLP_ICP_6M".into(),
],
);
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let bootstrapper = MultiCurveBootstrapper::new(vec![sofr_spec, icp_spec], policy);
let result = bootstrapper.bootstrap(&selector, Level::Mid);
assert!(
result.is_ok(),
"Multi-curve bootstrap failed: {:?}",
result.err()
);
let curves = result.unwrap();
assert!(curves.contains_key(&MarketIndex::SOFR));
assert!(curves.contains_key(&MarketIndex::ICP));
let df_sofr = curves[&MarketIndex::SOFR]
.curve()
.discount_factor(rd + Period::from_str("6M").unwrap())
.unwrap()
.value();
let df_icp = curves[&MarketIndex::ICP]
.curve()
.discount_factor(rd + Period::from_str("6M").unwrap())
.unwrap()
.value();
assert!(df_sofr < 1.0 && df_sofr > 0.9, "SOFR DF(6M)={df_sofr}");
assert!(df_icp < 1.0 && df_icp > 0.9, "ICP DF(6M)={df_icp}");
assert!(
df_icp < df_sofr,
"ICP DF({df_icp}) should be lower than SOFR DF({df_sofr}) because ICP rates are higher"
);
}
#[test]
fn bootstrapper_full_sofr_curve_with_sensitivities() {
use crate::ad::tape::Tape;
let rd = ref_date();
let mut selector = MapSelector::new(rd);
selector.add("FixedRateDeposit_USD_SOFR_1M", 0.0530);
selector.add("FixedRateDeposit_USD_SOFR_3M", 0.0525);
selector.add("FixedRateDeposit_USD_SOFR_6M", 0.0510);
selector.add("OIS_USD_SOFR_1Y", 0.0485);
selector.add("OIS_USD_SOFR_2Y", 0.0440);
selector.add("OIS_USD_SOFR_3Y", 0.0415);
selector.add("OIS_USD_SOFR_5Y", 0.0400);
selector.add("OIS_USD_SOFR_7Y", 0.0395);
selector.add("OIS_USD_SOFR_10Y", 0.0390);
let input_ids: Vec<&str> = vec![
"FixedRateDeposit_USD_SOFR_1M",
"FixedRateDeposit_USD_SOFR_3M",
"FixedRateDeposit_USD_SOFR_6M",
"OIS_USD_SOFR_1Y",
"OIS_USD_SOFR_2Y",
"OIS_USD_SOFR_3Y",
"OIS_USD_SOFR_5Y",
"OIS_USD_SOFR_7Y",
"OIS_USD_SOFR_10Y",
];
let base_rates: Vec<f64> = vec![
0.0530, 0.0525, 0.0510, 0.0485, 0.0440, 0.0415, 0.0400, 0.0395, 0.0390,
];
let do_bootstrap = |sel: &MapSelector| {
let spec = CurveSpec::new(
MarketIndex::SOFR,
Currency::USD,
DayCounter::Actual360,
Interpolator::LogLinear,
true,
vec![
"FixedRateDeposit_USD_SOFR_1M".into(),
"FixedRateDeposit_USD_SOFR_3M".into(),
"FixedRateDeposit_USD_SOFR_6M".into(),
"OIS_USD_SOFR_1Y".into(),
"OIS_USD_SOFR_2Y".into(),
"OIS_USD_SOFR_3Y".into(),
"OIS_USD_SOFR_5Y".into(),
"OIS_USD_SOFR_7Y".into(),
"OIS_USD_SOFR_10Y".into(),
],
);
let policy = BootstrapDiscountPolicy::new(MarketIndex::SOFR, Currency::USD);
let bootstrapper = MultiCurveBootstrapper::new(vec![spec], policy);
bootstrapper.bootstrap(sel, Level::Mid)
};
{
let curves = do_bootstrap(&selector).expect("Base SOFR bootstrap failed");
let elem = &curves[&MarketIndex::SOFR];
let curve = elem.curve();
let pillar_labels = curve
.pillar_labels()
.expect("Bootstrapped curve must have pillar labels");
println!("\n========== SOFR Bootstrapped Curve ==========");
println!("Reference date: {rd}");
println!(
"{:<38} {:>14} {:>14} {:>14}",
"Pillar", "Quote (%)", "DF", "Zero Rate (%)"
);
println!("{}", "-".repeat(80));
let pillars = curve.pillars().expect("pillars");
for (label, quote_val) in &pillars {
let q_pct = quote_val.value() * 100.0;
let pillar_date = pillar_date_from_label(rd, label);
let df = curve.discount_factor(pillar_date).unwrap().value();
let yf = DayCounter::Actual360.year_fraction(rd, pillar_date);
let zero_pct = if yf > 0.0 { -df.ln() / yf * 100.0 } else { 0.0 };
println!("{label:<38} {q_pct:>14.4} {df:>14.8} {zero_pct:>14.4}");
}
assert!(
pillar_labels.len() >= 9,
"Expected at least 9 pillars (one per instrument)"
);
}
let n_inputs = input_ids.len();
let target_tenors = ["2M", "4M", "9M", "18M", "4Y", "6Y", "8Y"];
let ad_sensitivities = |sel: &MapSelector, target: Date| -> (Vec<(String, f64)>, f64) {
Tape::start_recording();
let mut curves = do_bootstrap(sel).expect("Bootstrap inside AD pass failed");
Tape::reset_mark();
let elem = curves.get_mut(&MarketIndex::SOFR).unwrap();
let mut curve = elem.curve_mut();
curve.put_pillars_on_tape();
let df_target = curve.discount_factor(target).unwrap();
let val = df_target.value();
df_target.backward_to_mark().unwrap();
let sens: Vec<(String, f64)> = curve
.pillars()
.expect("pillars")
.iter()
.map(|(lbl, v)| (lbl.clone(), v.adjoint().unwrap_or(0.0)))
.collect();
Tape::stop_recording();
Tape::rewind_to_init();
(sens, val)
};
println!("\n========== AD Sensitivities: ∂DF(target)/∂market_input ==========",);
print!("{:<10} {:>14} ", "Target", "DF(target)");
for id in &input_ids {
print!("{:>14}", short_label(id));
}
println!();
println!("{}", "-".repeat(10 + 14 + 2 + 14 * n_inputs));
let mut ad_results: Vec<(&str, Vec<f64>, f64)> = Vec::new();
for tenor_str in &target_tenors {
let target_date = rd + Period::from_str(tenor_str).unwrap();
let (sens, df_val) = ad_sensitivities(&selector, target_date);
print!("{tenor_str:<10} {df_val:>14.8} ");
let adjoints: Vec<f64> = sens.iter().map(|(_, a)| *a).collect();
for adj in &adjoints {
print!("{adj:>14.6}");
}
println!();
ad_results.push((tenor_str, adjoints, df_val));
}
let fd_bump = 1e-4;
println!(
"\n========== FD Sensitivities: ∂DF(target)/∂market_input (central FD, 1 bp) ==========",
);
print!("{:<10} {:>14} ", "Target", "DF(target)");
for id in &input_ids {
print!("{:>14}", short_label(id));
}
println!();
println!("{}", "-".repeat(10 + 14 + 2 + 14 * n_inputs));
let mut max_rel_err: f64 = 0.0;
for (tenor_str, ad_adj, base_df) in &ad_results {
let target_date = rd + Period::from_str(tenor_str).unwrap();
print!("{tenor_str:<10} {base_df:>14.8} ");
for (j, &id) in input_ids.iter().enumerate() {
let half = fd_bump / 2.0;
let mut up_sel = MapSelector::new(rd);
let mut dn_sel = MapSelector::new(rd);
for (k, &base_id) in input_ids.iter().enumerate() {
let up = if j == k {
base_rates[k] + half
} else {
base_rates[k]
};
let dn = if j == k {
base_rates[k] - half
} else {
base_rates[k]
};
up_sel.add(base_id, up);
dn_sel.add(base_id, dn);
}
let up_curves = do_bootstrap(&up_sel)
.unwrap_or_else(|e| panic!("FD bump+ bootstrap failed for {id}: {e:?}"));
let dn_curves = do_bootstrap(&dn_sel)
.unwrap_or_else(|e| panic!("FD bump- bootstrap failed for {id}: {e:?}"));
let df_up = up_curves[&MarketIndex::SOFR]
.curve()
.discount_factor(target_date)
.unwrap()
.value();
let df_dn = dn_curves[&MarketIndex::SOFR]
.curve()
.discount_factor(target_date)
.unwrap()
.value();
let fd_sens = (df_up - df_dn) / fd_bump;
print!("{fd_sens:>14.6}");
let ad_val = ad_adj[j];
let scale = ad_val.abs().max(fd_sens.abs());
if scale > 1e-6 {
let rel = (ad_val - fd_sens).abs() / scale;
max_rel_err = max_rel_err.max(rel);
}
if ad_val.abs() > 1e-6 || fd_sens.abs() > 1e-6 {
assert!(
ad_val.signum() == fd_sens.signum()
|| ad_val.abs() < 1e-4
|| fd_sens.abs() < 1e-4,
"AD/FD sign mismatch at target={tenor_str}, input={id}: \
AD={ad_val:.8}, FD={fd_sens:.8}"
);
}
let _ = id;
}
println!();
}
println!("\nMax relative error (AD vs FD): {max_rel_err:.2e}");
assert!(
max_rel_err < 1e-4,
"AD vs FD maximum relative error {max_rel_err:.2e} exceeds 1e-4"
);
println!("\n========== AD vs FD agreement verified ==========\n");
}
fn short_label(label: &str) -> &str {
label
.strip_prefix("FixedRateDeposit_USD_")
.or_else(|| label.strip_prefix("OIS_USD_"))
.unwrap_or(label)
}
fn pillar_date_from_label(reference_date: Date, label: &str) -> Date {
let tenor_str = label.rsplit('_').next().unwrap_or("0D");
let period = Period::from_str(tenor_str)
.unwrap_or(Period::new(0, crate::time::enums::TimeUnit::Days));
reference_date + period
}
}