use crate::{
ad::{dual::DualFwd, scalar::Scalar},
time::{date::Date, daycounter::DayCounter},
};
pub trait PfeAggregator<T: Scalar>: Send + Sync {
fn name(&self) -> &'static str;
fn aggregate_path(&self, npvs: &[T], dates: &[Date]) -> T;
}
pub struct AggregatorBundle {
pub aggregator: Box<dyn PfeAggregator<DualFwd>>,
pub leaves: Vec<(String, DualFwd)>,
}
pub trait PfeAggregatorFactory: Send + Sync {
fn name(&self) -> &'static str;
fn create_aggregator(&self, ref_date: Date, dates: &[Date]) -> AggregatorBundle;
}
pub struct CvaAggregator<T: Scalar> {
lgd: T,
survival_probs: Vec<T>,
system_discounts: Option<Vec<f64>>,
inv_n: f64,
}
impl<T: Scalar> CvaAggregator<T> {
pub fn new(
credit_spread: T,
recovery: T,
n_paths: usize,
ref_date: Date,
dates: &[Date],
) -> Self {
let lgd = T::one().sub_val(recovery);
let hazard_rate = credit_spread.div_val(lgd);
let dc = DayCounter::Actual365;
let survival_probs: Vec<T> = dates
.iter()
.map(|d| {
let t = dc.year_fraction(ref_date, *d);
hazard_rate.neg_val().mul_val(T::scalar(t)).exp()
})
.collect();
Self {
lgd,
survival_probs,
system_discounts: None,
inv_n: 1.0 / f64::from(u32::try_from(n_paths).unwrap_or(u32::MAX)),
}
}
pub fn from_survival_probs(recovery: T, survival_probs: Vec<T>, n_paths: usize) -> Self {
Self {
lgd: T::one().sub_val(recovery),
survival_probs,
system_discounts: None,
inv_n: 1.0 / f64::from(u32::try_from(n_paths).unwrap_or(u32::MAX)),
}
}
#[must_use]
pub fn with_system_discounts(mut self, discounts: Vec<f64>) -> Self {
self.system_discounts = Some(discounts);
self
}
}
impl<T: Scalar> PfeAggregator<T> for CvaAggregator<T> {
fn name(&self) -> &'static str {
"CVA"
}
fn aggregate_path(&self, npvs: &[T], dates: &[Date]) -> T {
let mut c_p = T::zero();
let n = dates.len().min(npvs.len());
for (d, npv) in npvs.iter().enumerate().take(n).skip(1) {
let mut exposure = npv.max_val(T::zero());
if let Some(dfs) = &self.system_discounts {
exposure = exposure.mul_val(T::scalar(dfs[d]));
}
let delta_pd = self.survival_probs[d - 1].sub_val(self.survival_probs[d]);
c_p = c_p.add_val(exposure.mul_val(delta_pd));
}
c_p.mul_val(self.lgd).mul_val(T::scalar(self.inv_n))
}
}
pub struct DvaAggregator<T: Scalar> {
lgd: T,
survival_probs: Vec<T>,
system_discounts: Option<Vec<f64>>,
inv_n: f64,
}
impl<T: Scalar> DvaAggregator<T> {
pub fn new(
own_spread: T,
own_recovery: T,
n_paths: usize,
ref_date: Date,
dates: &[Date],
) -> Self {
let lgd = T::one().sub_val(own_recovery);
let hazard_rate = own_spread.div_val(lgd);
let dc = DayCounter::Actual365;
let survival_probs: Vec<T> = dates
.iter()
.map(|d| {
let t = dc.year_fraction(ref_date, *d);
hazard_rate.neg_val().mul_val(T::scalar(t)).exp()
})
.collect();
Self {
lgd,
survival_probs,
system_discounts: None,
inv_n: 1.0 / f64::from(u32::try_from(n_paths).unwrap_or(u32::MAX)),
}
}
#[must_use]
pub fn with_system_discounts(mut self, discounts: Vec<f64>) -> Self {
self.system_discounts = Some(discounts);
self
}
}
impl<T: Scalar> PfeAggregator<T> for DvaAggregator<T> {
fn name(&self) -> &'static str {
"DVA"
}
fn aggregate_path(&self, npvs: &[T], dates: &[Date]) -> T {
let mut d_p = T::zero();
let n = dates.len().min(npvs.len());
for (d, npv) in npvs.iter().enumerate().take(n).skip(1) {
let mut exposure = npv.neg_val().max_val(T::zero());
if let Some(dfs) = &self.system_discounts {
exposure = exposure.mul_val(T::scalar(dfs[d]));
}
let delta_pd = self.survival_probs[d - 1].sub_val(self.survival_probs[d]);
d_p = d_p.add_val(exposure.mul_val(delta_pd));
}
d_p.mul_val(self.lgd).mul_val(T::scalar(self.inv_n))
}
}
enum FundingSpreadTerm<T> {
Flat(T),
PerDate(Vec<T>),
}
pub struct FvaAggregator<T: Scalar> {
spread: FundingSpreadTerm<T>,
system_discounts: Option<Vec<f64>>,
inv_n: f64,
}
impl<T: Scalar> FvaAggregator<T> {
pub fn new(funding_spread: T, n_paths: usize) -> Self {
Self {
spread: FundingSpreadTerm::Flat(funding_spread),
system_discounts: None,
inv_n: 1.0 / f64::from(u32::try_from(n_paths).unwrap_or(u32::MAX)),
}
}
#[must_use]
pub fn from_spreads(spreads: Vec<T>, n_paths: usize) -> Self {
Self {
spread: FundingSpreadTerm::PerDate(spreads),
system_discounts: None,
inv_n: 1.0 / f64::from(u32::try_from(n_paths).unwrap_or(u32::MAX)),
}
}
#[must_use]
pub fn with_system_discounts(mut self, discounts: Vec<f64>) -> Self {
self.system_discounts = Some(discounts);
self
}
}
impl<T: Scalar> PfeAggregator<T> for FvaAggregator<T> {
fn name(&self) -> &'static str {
"FVA"
}
fn aggregate_path(&self, npvs: &[T], dates: &[Date]) -> T {
let dc = DayCounter::Actual365;
let mut f_p = T::zero();
for d in 1..dates.len().min(npvs.len()) {
let dt = dc.year_fraction(dates[d - 1], dates[d]);
let spread = match &self.spread {
FundingSpreadTerm::Flat(s) => *s,
FundingSpreadTerm::PerDate(v) => v[d],
};
let mut term = npvs[d].mul_val(spread).mul_val(T::scalar(dt));
if let Some(dfs) = &self.system_discounts {
term = term.mul_val(T::scalar(dfs[d]));
}
f_p = f_p.add_val(term);
}
f_p.mul_val(T::scalar(self.inv_n))
}
}
pub struct CvaFactory {
pub credit_spread: f64,
pub recovery: f64,
pub n_paths: usize,
pub system_dfs: Option<Vec<f64>>,
}
impl PfeAggregatorFactory for CvaFactory {
fn name(&self) -> &'static str {
"CVA"
}
fn create_aggregator(&self, ref_date: Date, dates: &[Date]) -> AggregatorBundle {
let cs = DualFwd::new(self.credit_spread);
let rec = DualFwd::new(self.recovery);
let mut agg = CvaAggregator::new(cs, rec, self.n_paths, ref_date, dates);
if let Some(dfs) = &self.system_dfs {
agg = agg.with_system_discounts(dfs.clone());
}
AggregatorBundle {
aggregator: Box::new(agg),
leaves: vec![
("CVA.credit_spread".to_string(), cs),
("CVA.recovery".to_string(), rec),
],
}
}
}
pub struct CreditCurveCvaFactory {
pub pillar_dates: Vec<Date>,
pub pillar_survivals: Vec<f64>,
pub pillar_labels: Vec<String>,
pub recovery: f64,
pub n_paths: usize,
pub day_counter: DayCounter,
pub system_dfs: Option<Vec<f64>>,
}
impl CreditCurveCvaFactory {
fn survival_at(t: f64, pillar_times: &[f64], leaves: &[DualFwd]) -> DualFwd {
let n = pillar_times.len();
if t <= 0.0 || n == 0 {
return DualFwd::scalar(1.0);
}
if t <= pillar_times[0] {
let w = t / pillar_times[0];
return leaves[0].ln().mul_val(DualFwd::scalar(w)).exp();
}
for k in 1..n {
if t <= pillar_times[k] {
let w = (t - pillar_times[k - 1]) / (pillar_times[k] - pillar_times[k - 1]);
let ln_s = leaves[k - 1].ln().add_val(
leaves[k]
.ln()
.sub_val(leaves[k - 1].ln())
.mul_val(DualFwd::scalar(w)),
);
return ln_s.exp();
}
}
let last = n - 1;
let (t_prev, ln_prev) = if n >= 2 {
(pillar_times[last - 1], leaves[last - 1].ln())
} else {
(0.0, DualFwd::scalar(0.0))
};
let dt_bucket = pillar_times[last] - t_prev;
let hazard = ln_prev
.sub_val(leaves[last].ln())
.div_val(DualFwd::scalar(dt_bucket));
leaves[last]
.ln()
.sub_val(hazard.mul_val(DualFwd::scalar(t - pillar_times[last])))
.exp()
}
}
impl PfeAggregatorFactory for CreditCurveCvaFactory {
fn name(&self) -> &'static str {
"CVA"
}
fn create_aggregator(&self, ref_date: Date, dates: &[Date]) -> AggregatorBundle {
let pillar_leaves: Vec<DualFwd> = self
.pillar_survivals
.iter()
.map(|s| DualFwd::new(*s))
.collect();
let rec = DualFwd::new(self.recovery);
let pillar_times: Vec<f64> = self
.pillar_dates
.iter()
.map(|d| self.day_counter.year_fraction(ref_date, *d))
.collect();
let survival_probs: Vec<DualFwd> = dates
.iter()
.map(|d| {
let t = self.day_counter.year_fraction(ref_date, *d);
Self::survival_at(t, &pillar_times, &pillar_leaves)
})
.collect();
let mut agg = CvaAggregator::from_survival_probs(rec, survival_probs, self.n_paths);
if let Some(dfs) = &self.system_dfs {
agg = agg.with_system_discounts(dfs.clone());
}
let mut leaves: Vec<(String, DualFwd)> = self
.pillar_labels
.iter()
.zip(&pillar_leaves)
.map(|(label, leaf)| (format!("CVA.{label}"), *leaf))
.collect();
leaves.push(("CVA.recovery".to_string(), rec));
AggregatorBundle {
aggregator: Box::new(agg),
leaves,
}
}
}
pub struct DvaFactory {
pub own_spread: f64,
pub own_recovery: f64,
pub n_paths: usize,
}
impl PfeAggregatorFactory for DvaFactory {
fn name(&self) -> &'static str {
"DVA"
}
fn create_aggregator(&self, ref_date: Date, dates: &[Date]) -> AggregatorBundle {
let sp = DualFwd::new(self.own_spread);
let rec = DualFwd::new(self.own_recovery);
let agg = DvaAggregator::new(sp, rec, self.n_paths, ref_date, dates);
AggregatorBundle {
aggregator: Box::new(agg),
leaves: vec![
("DVA.own_spread".to_string(), sp),
("DVA.own_recovery".to_string(), rec),
],
}
}
}
pub struct FvaFactory {
pub funding_spread: f64,
pub n_paths: usize,
pub system_dfs: Option<Vec<f64>>,
}
impl PfeAggregatorFactory for FvaFactory {
fn name(&self) -> &'static str {
"FVA"
}
fn create_aggregator(&self, _ref_date: Date, _dates: &[Date]) -> AggregatorBundle {
let fs = DualFwd::new(self.funding_spread);
let mut agg = FvaAggregator::new(fs, self.n_paths);
if let Some(dfs) = &self.system_dfs {
agg = agg.with_system_discounts(dfs.clone());
}
AggregatorBundle {
aggregator: Box::new(agg),
leaves: vec![("FVA.funding_spread".to_string(), fs)],
}
}
}
pub struct FundingCurveFvaFactory {
pub pillar_dates: Vec<Date>,
pub pillar_spreads: Vec<f64>,
pub pillar_labels: Vec<String>,
pub overlay_dates: Vec<Date>,
pub overlay_spreads: Vec<f64>,
pub overlay_labels: Vec<String>,
pub n_paths: usize,
pub day_counter: DayCounter,
pub system_dfs: Option<Vec<f64>>,
}
impl FundingCurveFvaFactory {
fn spread_at(t: f64, pillar_times: &[f64], leaves: &[DualFwd]) -> DualFwd {
let n = pillar_times.len();
if n == 0 {
return DualFwd::scalar(0.0);
}
if t <= pillar_times[0] {
return leaves[0];
}
for k in 1..n {
if t <= pillar_times[k] {
let w = (t - pillar_times[k - 1]) / (pillar_times[k] - pillar_times[k - 1]);
return leaves[k - 1]
.add_val(leaves[k].sub_val(leaves[k - 1]).mul_val(DualFwd::scalar(w)));
}
}
leaves[n - 1]
}
}
impl PfeAggregatorFactory for FundingCurveFvaFactory {
fn name(&self) -> &'static str {
"FVA"
}
fn create_aggregator(&self, ref_date: Date, dates: &[Date]) -> AggregatorBundle {
let pillar_leaves: Vec<DualFwd> = self
.pillar_spreads
.iter()
.map(|s| DualFwd::new(*s))
.collect();
let overlay_leaves: Vec<DualFwd> = self
.overlay_spreads
.iter()
.map(|s| DualFwd::new(*s))
.collect();
let pillar_times: Vec<f64> = self
.pillar_dates
.iter()
.map(|d| self.day_counter.year_fraction(ref_date, *d))
.collect();
let overlay_times: Vec<f64> = self
.overlay_dates
.iter()
.map(|d| self.day_counter.year_fraction(ref_date, *d))
.collect();
let spreads: Vec<DualFwd> = dates
.iter()
.map(|d| {
let t = self.day_counter.year_fraction(ref_date, *d);
let base = Self::spread_at(t, &pillar_times, &pillar_leaves);
if overlay_leaves.is_empty() {
base
} else {
base.add_val(Self::spread_at(t, &overlay_times, &overlay_leaves))
}
})
.collect();
let mut agg = FvaAggregator::from_spreads(spreads, self.n_paths);
if let Some(dfs) = &self.system_dfs {
agg = agg.with_system_discounts(dfs.clone());
}
let leaves: Vec<(String, DualFwd)> = self
.pillar_labels
.iter()
.zip(&pillar_leaves)
.chain(self.overlay_labels.iter().zip(&overlay_leaves))
.map(|(label, leaf)| (format!("FVA.{label}"), *leaf))
.collect();
AggregatorBundle {
aggregator: Box::new(agg),
leaves,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ad::tape::Tape, time::enums::TimeUnit};
const DC: DayCounter = DayCounter::Actual365;
fn sim_dates(ref_date: Date, months: &[i32]) -> Vec<Date> {
std::iter::once(ref_date)
.chain(
months
.iter()
.map(|m| ref_date.advance(*m, TimeUnit::Months)),
)
.collect()
}
#[test]
fn cva_constant_exposure_telescopes() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[3, 6, 9, 12, 24, 36]);
let (spread, recovery, exposure) = (0.02_f64, 0.4_f64, 1_000.0_f64);
let lgd = 1.0 - recovery;
let agg = CvaAggregator::<f64>::new(spread, recovery, 1, ref_date, &dates);
let npvs = vec![exposure; dates.len()];
let cva = agg.aggregate_path(&npvs, &dates);
let t_last = DC.year_fraction(ref_date, dates[dates.len() - 1]);
let expected = exposure * lgd * (1.0 - (-(spread / lgd) * t_last).exp());
assert!(
(cva - expected).abs() < 1e-12 * expected,
"telescoping CVA {cva} vs analytic {expected}"
);
}
#[test]
fn cva_negative_exposure_is_zero() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[6, 12, 24]);
let agg = CvaAggregator::<f64>::new(0.02, 0.4, 1, ref_date, &dates);
let npvs = vec![-500.0; dates.len()];
assert!(agg.aggregate_path(&npvs, &dates).abs() < 1e-15);
}
#[test]
fn dva_mirrors_cva() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[3, 12, 30, 60]);
let cva = CvaAggregator::<f64>::new(0.015, 0.35, 4, ref_date, &dates);
let dva = DvaAggregator::<f64>::new(0.015, 0.35, 4, ref_date, &dates);
let pos = vec![750.0; dates.len()];
let neg: Vec<f64> = pos.iter().map(|v| -v).collect();
let c = cva.aggregate_path(&pos, &dates);
let d = dva.aggregate_path(&neg, &dates);
assert!(
(c - d).abs() < 1e-14 * c.abs(),
"CVA {c} vs mirrored DVA {d}"
);
}
#[test]
fn fva_constant_npv_analytic() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[6, 12, 24, 48]);
let (funding_spread, exposure) = (0.005_f64, 2_000.0_f64);
let npvs = vec![exposure; dates.len()];
let agg = FvaAggregator::<f64>::new(funding_spread, 1);
let fva = agg.aggregate_path(&npvs, &dates);
let t_last = DC.year_fraction(ref_date, dates[dates.len() - 1]);
let expected = exposure * funding_spread * t_last;
assert!(
(fva - expected).abs() < 1e-12 * expected,
"FVA {fva} vs analytic {expected}"
);
let discounted = FvaAggregator::<f64>::new(funding_spread, 1)
.with_system_discounts(vec![1.0; dates.len()])
.aggregate_path(&npvs, &dates);
assert!((discounted - fva).abs() < 1e-15 * fva.abs());
}
#[test]
fn cva_system_discounts_apply_per_bucket() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[12, 24, 36]);
let (spread, recovery, exposure, r) = (0.02_f64, 0.4_f64, 1_000.0_f64, 0.03_f64);
let lgd = 1.0 - recovery;
let hazard = spread / lgd;
let dfs: Vec<f64> = dates
.iter()
.map(|d| (-r * DC.year_fraction(ref_date, *d)).exp())
.collect();
let agg = CvaAggregator::<f64>::new(spread, recovery, 1, ref_date, &dates)
.with_system_discounts(dfs.clone());
let npvs = vec![exposure; dates.len()];
let cva = agg.aggregate_path(&npvs, &dates);
let mut expected = 0.0;
let mut s_prev = 1.0;
for (d, date) in dates.iter().enumerate().skip(1) {
let t = DC.year_fraction(ref_date, *date);
let s = (-hazard * t).exp();
expected += exposure * dfs[d] * (s_prev - s);
s_prev = s;
}
expected *= lgd;
assert!(
(cva - expected).abs() < 1e-12 * expected,
"discounted CVA {cva} vs hand-computed {expected}"
);
}
#[test]
fn credit_curve_factory_matches_flat_hazard() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[3, 9, 18, 30, 54, 84]);
let (lambda, recovery, n_paths) = (0.04_f64, 0.4_f64, 2_usize);
let lgd = 1.0 - recovery;
let pillar_dates: Vec<Date> = [1, 3, 5]
.iter()
.map(|y| ref_date.advance(*y, TimeUnit::Years))
.collect();
let pillar_survivals: Vec<f64> = pillar_dates
.iter()
.map(|d| (-lambda * DC.year_fraction(ref_date, *d)).exp())
.collect();
let curve_factory = CreditCurveCvaFactory {
pillar_dates,
pillar_survivals,
pillar_labels: vec!["1Y".into(), "3Y".into(), "5Y".into()],
recovery,
n_paths,
day_counter: DC,
system_dfs: None,
};
let flat_factory = CvaFactory {
credit_spread: lambda * lgd,
recovery,
n_paths,
system_dfs: None,
};
Tape::start_recording_fwd();
let curve_bundle = curve_factory.create_aggregator(ref_date, &dates);
let flat_bundle = flat_factory.create_aggregator(ref_date, &dates);
Tape::set_mark_fwd();
let npvs: Vec<DualFwd> = dates.iter().map(|_| DualFwd::scalar(1_000.0)).collect();
let from_curve = curve_bundle
.aggregator
.aggregate_path(&npvs, &dates)
.value();
let from_flat = flat_bundle.aggregator.aggregate_path(&npvs, &dates).value();
Tape::stop_recording_fwd();
assert!(from_curve > 0.0);
assert!(
(from_curve - from_flat).abs() < 1e-10 * from_flat,
"curve-driven CVA {from_curve} vs flat-spread CVA {from_flat}"
);
assert!(
curve_bundle.leaves.iter().any(|(l, _)| l == "CVA.5Y"),
"pillar leaves must be labeled with the quote ids"
);
}
#[test]
fn single_pillar_flat_hazard_extrapolation() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[6, 12, 36]); let (lambda, recovery) = (0.05_f64, 0.4_f64);
let pillar_date = ref_date.advance(1, TimeUnit::Years);
let s1 = (-lambda * DC.year_fraction(ref_date, pillar_date)).exp();
let factory = CreditCurveCvaFactory {
pillar_dates: vec![pillar_date],
pillar_survivals: vec![s1],
pillar_labels: vec!["1Y".into()],
recovery,
n_paths: 1,
day_counter: DC,
system_dfs: None,
};
let flat = CvaFactory {
credit_spread: lambda * (1.0 - recovery),
recovery,
n_paths: 1,
system_dfs: None,
};
Tape::start_recording_fwd();
let curve_bundle = factory.create_aggregator(ref_date, &dates);
let flat_bundle = flat.create_aggregator(ref_date, &dates);
Tape::set_mark_fwd();
let npvs: Vec<DualFwd> = dates.iter().map(|_| DualFwd::scalar(500.0)).collect();
let from_curve = curve_bundle
.aggregator
.aggregate_path(&npvs, &dates)
.value();
let from_flat = flat_bundle.aggregator.aggregate_path(&npvs, &dates).value();
Tape::stop_recording_fwd();
assert!(
(from_curve - from_flat).abs() < 1e-10 * from_flat,
"single-pillar extrapolated CVA {from_curve} vs flat {from_flat}"
);
}
#[test]
fn funding_curve_flat_spreads_match_flat_fva() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[6, 12, 24, 48]);
let spread = 0.005_f64;
let curve_factory = FundingCurveFvaFactory {
pillar_dates: vec![
ref_date.advance(1, TimeUnit::Years),
ref_date.advance(3, TimeUnit::Years),
],
pillar_spreads: vec![spread, spread],
pillar_labels: vec!["1Y".into(), "3Y".into()],
overlay_dates: vec![],
overlay_spreads: vec![],
overlay_labels: vec![],
n_paths: 1,
day_counter: DC,
system_dfs: None,
};
let flat_factory = FvaFactory {
funding_spread: spread,
n_paths: 1,
system_dfs: None,
};
Tape::start_recording_fwd();
let curve_bundle = curve_factory.create_aggregator(ref_date, &dates);
let flat_bundle = flat_factory.create_aggregator(ref_date, &dates);
Tape::set_mark_fwd();
let npvs: Vec<DualFwd> = dates.iter().map(|_| DualFwd::scalar(2_000.0)).collect();
let from_curve = curve_bundle
.aggregator
.aggregate_path(&npvs, &dates)
.value();
let from_flat = flat_bundle.aggregator.aggregate_path(&npvs, &dates).value();
Tape::stop_recording_fwd();
assert!(
(from_curve - from_flat).abs() < 1e-12 * from_flat.abs(),
"curve-driven FVA {from_curve} vs flat FVA {from_flat}"
);
assert!(
curve_bundle.leaves.iter().any(|(l, _)| l == "FVA.1Y"),
"pillar leaves must be labeled with the pillar ids"
);
}
#[test]
fn funding_curve_time_dependent_spreads_analytic() {
let ref_date = Date::new(2025, 1, 2);
let dates = sim_dates(ref_date, &[12, 24, 36]);
let exposure = 1_000.0_f64;
let pillar_dates = vec![dates[1], dates[3]];
let (s1, s3) = (0.002_f64, 0.006_f64);
let factory = FundingCurveFvaFactory {
pillar_dates: pillar_dates.clone(),
pillar_spreads: vec![s1, s3],
pillar_labels: vec!["1Y".into(), "3Y".into()],
overlay_dates: vec![],
overlay_spreads: vec![],
overlay_labels: vec![],
n_paths: 1,
day_counter: DC,
system_dfs: None,
};
Tape::start_recording_fwd();
let bundle = factory.create_aggregator(ref_date, &dates);
Tape::set_mark_fwd();
let npvs: Vec<DualFwd> = dates.iter().map(|_| DualFwd::scalar(exposure)).collect();
let fva = bundle.aggregator.aggregate_path(&npvs, &dates).value();
Tape::stop_recording_fwd();
let times: Vec<f64> = dates
.iter()
.map(|d| DC.year_fraction(ref_date, *d))
.collect();
let (t1, t3) = (
DC.year_fraction(ref_date, pillar_dates[0]),
DC.year_fraction(ref_date, pillar_dates[1]),
);
let spread_at = |t: f64| {
if t <= t1 {
s1
} else if t >= t3 {
s3
} else {
s1 + (s3 - s1) * (t - t1) / (t3 - t1)
}
};
let expected: f64 = (1..times.len())
.map(|d| exposure * spread_at(times[d]) * (times[d] - times[d - 1]))
.sum();
assert!(
(fva - expected).abs() < 1e-12 * expected,
"time-dependent FVA {fva} vs analytic {expected}"
);
}
}