use std::cell::RefCell;
use std::rc::Weak;
use crate::errors::QlResult;
use crate::math::interpolations::linear::Linear;
use crate::math::interpolations::{Interpolation, Interpolator};
use crate::patterns::lazyobject::LazyObject;
use crate::patterns::observable::{AsObservable, Observable, Observer};
use crate::require;
use crate::shared::{Shared, SharedMut, shared_mut};
use crate::termstructures::bootstraptraits::CurveData;
use crate::termstructures::inflation::inflationhelpers::ZeroInflationHelper;
use crate::termstructures::inflation::inflationtermstructure::{
InflationTermStructure, InflationTermStructureBase, ZeroInflationTermStructure,
};
use crate::termstructures::inflation::inflationtraits::ZeroInflationTraits;
use crate::termstructures::inflation::seasonality::Seasonality;
use crate::termstructures::iterativebootstrap::{IterativeBootstrap, PiecewiseCurve};
use crate::termstructures::{TermStructure, TermStructureBase};
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::types::{Rate, Real, Time};
struct CurveUpdater {
lazy: SharedMut<LazyObject>,
}
impl Observer for CurveUpdater {
fn update(&mut self) {
if let Some(update) = LazyObject::deferred_update(&self.lazy) {
update.notify_observers();
}
}
}
pub struct PiecewiseZeroInflationCurve<I: Interpolator> {
inflation: InflationTermStructureBase,
instruments: Vec<Shared<dyn ZeroInflationHelper>>,
interpolator: I,
data: RefCell<CurveData<I>>,
lazy: SharedMut<LazyObject>,
observable: Shared<Observable>,
updater: SharedMut<CurveUpdater>,
bootstrap: IterativeBootstrap,
accuracy: Real,
self_weak: Weak<dyn ZeroInflationTermStructure>,
}
impl PiecewiseZeroInflationCurve<Linear> {
pub fn new(
reference_date: Date,
base_date: Date,
frequency: Frequency,
day_counter: DayCounter,
instruments: Vec<Shared<dyn ZeroInflationHelper>>,
seasonality: Option<Shared<dyn Seasonality>>,
) -> QlResult<Shared<PiecewiseZeroInflationCurve<Linear>>> {
require!(!instruments.is_empty(), "no bootstrap helpers given");
let curve = Shared::new_cyclic(|weak: &Weak<PiecewiseZeroInflationCurve<Linear>>| {
let self_weak: Weak<dyn ZeroInflationTermStructure> = weak.clone();
let lazy = shared_mut(LazyObject::new(true));
let observable = lazy.borrow().observable_handle();
let updater = shared_mut(CurveUpdater {
lazy: SharedMut::clone(&lazy),
});
PiecewiseZeroInflationCurve {
inflation: InflationTermStructureBase::with_reference_date(
reference_date,
base_date,
frequency,
Some(day_counter),
None,
seasonality,
),
instruments,
interpolator: Linear,
data: RefCell::new(CurveData::new()),
lazy,
observable,
updater,
bootstrap: IterativeBootstrap::new(),
accuracy: 1.0e-14,
self_weak,
}
});
let observer = SharedMut::clone(&curve.updater) as SharedMut<dyn Observer>;
for helper in &curve.instruments {
helper.observable().register_observer(&observer);
}
curve.check_seasonality()?;
Ok(curve)
}
}
impl<I: Interpolator + 'static> PiecewiseZeroInflationCurve<I> {
pub fn calculate(&self) -> QlResult<()> {
if self.lazy.borrow().is_calculated() {
return Ok(());
}
if !self.lazy.borrow_mut().start_calculation() {
return Ok(());
}
let result = self.bootstrap.calculate(self);
self.lazy.borrow_mut().finish_calculation(&result);
result
}
pub fn times(&self) -> QlResult<Vec<Time>> {
self.calculate()?;
Ok(self.data.borrow().times().to_vec())
}
pub fn dates(&self) -> QlResult<Vec<Date>> {
self.calculate()?;
Ok(self.data.borrow().dates().to_vec())
}
pub fn data(&self) -> QlResult<Vec<Real>> {
self.calculate()?;
Ok(self.data.borrow().data().to_vec())
}
pub fn nodes(&self) -> QlResult<Vec<(Date, Real)>> {
self.calculate()?;
Ok(self.data.borrow().nodes())
}
pub fn register_observer(&self, observer: &SharedMut<dyn Observer>) -> bool {
self.observable.register_observer(observer)
}
}
impl<I: Interpolator> AsObservable for PiecewiseZeroInflationCurve<I> {
fn observable(&self) -> &Observable {
&self.observable
}
}
impl<I: Interpolator + 'static> TermStructure for PiecewiseZeroInflationCurve<I> {
fn base(&self) -> &TermStructureBase {
self.inflation.term_structure_base()
}
fn max_date(&self) -> Date {
let _ = self.calculate();
self.data
.borrow()
.max_date()
.or_else(|| self.inflation.term_structure_base().reference_date().ok())
.unwrap_or_else(Date::null)
}
}
impl<I: Interpolator + 'static> InflationTermStructure for PiecewiseZeroInflationCurve<I> {
fn inflation_base(&self) -> &InflationTermStructureBase {
&self.inflation
}
fn as_inflation_term_structure(&self) -> &dyn InflationTermStructure {
self
}
fn update_after_seasonality_change(&self) {
if let Some(update) = LazyObject::deferred_update(&self.lazy) {
update.notify_observers();
}
}
}
impl<I: Interpolator + 'static> ZeroInflationTermStructure for PiecewiseZeroInflationCurve<I> {
fn zero_rate_impl(&self, t: Time) -> QlResult<Rate> {
self.calculate()?;
let data = self.data.borrow();
data.interpolation()?.value(t)
}
}
impl<I: Interpolator + 'static> PiecewiseCurve for PiecewiseZeroInflationCurve<I> {
type Traits = ZeroInflationTraits;
type Interp = I;
type TS = dyn ZeroInflationTermStructure;
type Helper = dyn ZeroInflationHelper;
fn instruments(&self) -> &[Shared<dyn ZeroInflationHelper>] {
&self.instruments
}
fn interpolator(&self) -> &I {
&self.interpolator
}
fn curve_data(&self) -> &RefCell<CurveData<I>> {
&self.data
}
fn accuracy(&self) -> Real {
self.accuracy
}
fn reference_date(&self) -> QlResult<Date> {
self.inflation.term_structure_base().reference_date()
}
fn initial_date(&self) -> QlResult<Date> {
Ok(InflationTermStructure::base_date(self))
}
fn time_from_reference(&self, date: Date) -> QlResult<Time> {
TermStructure::time_from_reference(self, date)
}
fn term_structure_shared(&self) -> QlResult<Shared<dyn ZeroInflationTermStructure>> {
match self.self_weak.upgrade() {
Some(curve) => Ok(curve),
None => crate::fail!("curve dropped before bootstrap"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::Handle;
use crate::indexes::Index;
use crate::indexes::inflation::UkRpi;
use crate::indexes::inflationindex::{CpiInterpolationType, ZeroInflationIndex};
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::shared;
use crate::termstructures::inflation::inflationhelpers::ZeroCouponInflationSwapHelper;
use crate::termstructures::yields::Pillar;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendars::unitedkingdom::{Market, UnitedKingdom};
use crate::time::date::Month::{April, August, July, June, May};
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
const QUOTES: [Real; 3] = [0.029, 0.030, 0.031];
const MATURITY_YEARS: [i32; 3] = [1, 3, 5];
fn today() -> Date {
Date::new(13, August, 2007)
}
fn base_date() -> Date {
Date::new(1, July, 2007)
}
fn day_counter() -> DayCounter {
Thirty360::with_convention(Convention::BondBasis)
}
fn settings_today() -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today());
settings
}
fn an_index(settings: &Shared<Settings<Date>>) -> Shared<ZeroInflationIndex> {
let index = shared(UkRpi::new(Shared::clone(settings)));
for (date, fixing) in [
(Date::new(1, April, 2007), 204.4),
(Date::new(1, May, 2007), 205.4),
(Date::new(1, June, 2007), 206.2),
(base_date(), 207.3),
] {
index.add_fixing(date, fixing).expect("a published figure");
}
index
}
fn helpers(
settings: &Shared<Settings<Date>>,
index: &Shared<ZeroInflationIndex>,
) -> Vec<Shared<dyn ZeroInflationHelper>> {
QUOTES
.iter()
.zip(MATURITY_YEARS)
.map(|(quote, years)| {
ZeroCouponInflationSwapHelper::new(
Handle::new(shared(SimpleQuote::new(Some(*quote))) as Shared<dyn Quote>),
Period::new(3, TimeUnit::Months),
today() + Period::new(years, TimeUnit::Years),
UnitedKingdom::new(Market::Settlement),
BusinessDayConvention::ModifiedFollowing,
day_counter(),
index,
CpiInterpolationType::Flat,
Pillar::LastRelevantDate,
Shared::clone(settings),
)
.expect("a three-month lag covers UK RPI's availability")
as Shared<dyn ZeroInflationHelper>
})
.collect()
}
struct Fixture {
_settings: Shared<Settings<Date>>,
helpers: Vec<Shared<dyn ZeroInflationHelper>>,
curve: Shared<PiecewiseZeroInflationCurve<Linear>>,
}
fn a_curve() -> Fixture {
let settings = settings_today();
let index = an_index(&settings);
assert_eq!(
index.last_fixing_date().unwrap(),
base_date(),
"the curve's base date is the index's last published period"
);
let helpers = helpers(&settings, &index);
let curve = PiecewiseZeroInflationCurve::new(
today(),
base_date(),
Frequency::Monthly,
day_counter(),
helpers.clone(),
None,
)
.unwrap();
Fixture {
_settings: settings,
helpers,
curve,
}
}
#[test]
fn the_first_node_is_the_base_date_at_a_negative_time() {
let fixture = a_curve();
let (helpers, curve) = (&fixture.helpers, &fixture.curve);
assert_eq!(curve.dates().unwrap()[0], base_date());
assert_eq!(curve.times().unwrap()[0], -42.0 / 360.0);
assert_eq!(
curve.times().unwrap()[0],
TermStructure::time_from_reference(curve.as_ref(), base_date()).unwrap()
);
assert_eq!(
curve.dates().unwrap().len(),
helpers.len() + 1,
"one node per helper, plus the base-date node"
);
assert_eq!(curve.nodes().unwrap()[0].0, base_date());
}
#[test]
fn the_bootstrapped_curve_reproduces_the_quoted_swap_rates() {
let fixture = a_curve();
let (helpers, curve) = (&fixture.helpers, &fixture.curve);
curve.calculate().unwrap();
for (helper, quote) in helpers.iter().zip(QUOTES) {
let error = helper.quote_error().unwrap();
assert!(
error.abs() < 1.0e-12,
"quote error {error} on the {quote} helper"
);
}
}
#[test]
fn the_pillars_are_the_helpers_fixing_periods() {
let fixture = a_curve();
let (helpers, curve) = (&fixture.helpers, &fixture.curve);
let dates = curve.dates().unwrap();
for (i, helper) in helpers.iter().enumerate() {
assert_eq!(dates[i + 1], helper.pillar_date());
}
assert_eq!(dates[1], Date::new(1, May, 2008));
assert_eq!(curve.max_date(), *dates.last().unwrap());
for rate in curve.data().unwrap() {
assert!((0.01..0.05).contains(&rate), "node rate {rate}");
}
}
#[test]
fn the_bootstrap_is_lazy_and_reruns_on_a_quote_change() {
let settings = settings_today();
let index = an_index(&settings);
let quote = shared(SimpleQuote::new(Some(0.029)));
let helper = ZeroCouponInflationSwapHelper::new(
Handle::new(Shared::clone("e) as Shared<dyn Quote>),
Period::new(3, TimeUnit::Months),
today() + Period::new(5, TimeUnit::Years),
UnitedKingdom::new(Market::Settlement),
BusinessDayConvention::ModifiedFollowing,
day_counter(),
&index,
CpiInterpolationType::Flat,
Pillar::LastRelevantDate,
Shared::clone(&settings),
)
.unwrap();
let curve = PiecewiseZeroInflationCurve::new(
today(),
base_date(),
Frequency::Monthly,
day_counter(),
vec![Shared::clone(&helper) as Shared<dyn ZeroInflationHelper>],
None,
)
.unwrap();
assert!(!curve.lazy.borrow().is_calculated());
let first = curve.data().unwrap()[1];
assert!(curve.lazy.borrow().is_calculated());
assert!((0.01..0.05).contains(&first), "solved {first}");
quote.set_value(Some(0.04));
assert!(!curve.lazy.borrow().is_calculated());
let second = curve.data().unwrap()[1];
assert!(
second > first,
"a higher quoted rate must lift the curve: {second} vs {first}"
);
}
#[test]
fn an_empty_helper_set_is_rejected() {
let built = PiecewiseZeroInflationCurve::new(
today(),
base_date(),
Frequency::Monthly,
day_counter(),
Vec::new(),
None,
);
let err = match built {
Ok(_) => panic!("expected a construction error"),
Err(err) => err,
};
assert!(err.message().contains("no bootstrap helpers"));
}
}
#[cfg(test)]
mod zero_term_structure_oracle {
use super::*;
use crate::handle::{Handle, RelinkableHandle};
use crate::indexes::Index;
use crate::indexes::inflation::UkRpi;
use crate::indexes::inflationindex::{
CpiInterpolationType, ZeroInflationIndex, inflation_period,
};
use crate::instrument::Instrument;
use crate::instruments::{SwapType, ZeroCouponInflationSwap};
use crate::interestrate::Compounding;
use crate::pricingengine::PricingEngine;
use crate::pricingengines::DiscountingSwapEngine;
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::{SharedMut, shared, shared_mut};
use crate::termstructures::inflation::inflationhelpers::ZeroCouponInflationSwapHelper;
use crate::termstructures::inflation::seasonality::MultiplicativePriceSeasonality;
use crate::termstructures::yields::{FlatForward, Pillar};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::calendars::unitedkingdom::{Market, UnitedKingdom};
use crate::time::date::Month::{August, January, July, May};
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::period::Period;
use crate::time::schedule::MakeSchedule;
use crate::time::timeunit::TimeUnit;
const EPS: Real = 1.0e-7;
const BASIS_POINT: Real = 1.0e-4;
const NOMINAL: Real = 1_000_000.0;
const FIX_DATA: [Real; 31] = [
189.9, 189.9, 189.6, 190.5, 191.6, 192.0, 192.2, 192.2, 192.6, 193.1, 193.3, 193.6, 194.1,
193.4, 194.2, 195.0, 196.5, 197.7, 198.5, 198.5, 199.2, 200.1, 200.4, 201.1, 202.7, 201.6,
203.1, 204.4, 205.4, 206.2, 207.3,
];
fn zc_data() -> Vec<(Date, Real)> {
vec![
(Date::new(13, August, 2008), 2.93),
(Date::new(13, August, 2009), 2.95),
(Date::new(13, August, 2010), 2.965),
(Date::new(15, August, 2011), 2.98),
(Date::new(13, August, 2012), 3.0),
(Date::new(13, August, 2014), 3.06),
(Date::new(13, August, 2017), 3.175),
(Date::new(13, August, 2019), 3.243),
(Date::new(15, August, 2022), 3.293),
(Date::new(14, August, 2027), 3.338),
(Date::new(13, August, 2032), 3.348),
(Date::new(15, August, 2037), 3.348),
(Date::new(13, August, 2047), 3.308),
(Date::new(13, August, 2057), 3.228),
]
}
fn calendar() -> Calendar {
UnitedKingdom::new(Market::Settlement)
}
fn evaluation_date() -> Date {
Date::new(13, August, 2007)
}
fn day_counter() -> DayCounter {
Thirty360::with_convention(Convention::BondBasis)
}
fn observation_lag() -> Period {
Period::new(3, TimeUnit::Months)
}
struct Fixture {
settings: Shared<Settings<Date>>,
index: Shared<ZeroInflationIndex>,
nominal_ts: Handle<dyn YieldTermStructure>,
curve: Shared<PiecewiseZeroInflationCurve<Linear>>,
first_helper: Shared<ZeroCouponInflationSwapHelper>,
_hz: RelinkableHandle<dyn ZeroInflationTermStructure>,
}
impl Fixture {
fn a_swap(&self, maturity: Date, fixed_rate: Rate) -> ZeroCouponInflationSwap {
let mut swap = ZeroCouponInflationSwap::new(
SwapType::Payer,
NOMINAL,
evaluation_date(),
maturity,
calendar(),
BusinessDayConvention::ModifiedFollowing,
day_counter(),
fixed_rate,
Shared::clone(&self.index),
observation_lag(),
CpiInterpolationType::Flat,
None,
None,
Shared::clone(&self.settings),
)
.expect("a three-month lag covers UK RPI's availability");
let engine = DiscountingSwapEngine::new(
self.nominal_ts.clone(),
None,
None,
None,
Shared::clone(&self.settings),
);
swap.base_mut()
.set_pricing_engine(shared_mut(engine) as SharedMut<dyn PricingEngine>);
swap
}
}
fn a_fixture() -> Fixture {
assert_eq!(
calendar().adjust(evaluation_date(), BusinessDayConvention::ModifiedFollowing),
evaluation_date(),
"13 August 2007 is a UK business day, so C++'s adjust is the identity"
);
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(evaluation_date());
let hz: RelinkableHandle<dyn ZeroInflationTermStructure> = RelinkableHandle::empty();
let index = shared(UkRpi::new(Shared::clone(&settings)).with_term_structure(hz.handle()));
let first_fixing_date = Date::new(1, January, 2005);
for (i, fixing) in FIX_DATA.iter().enumerate() {
let date = first_fixing_date + Period::new(i as i32, TimeUnit::Months);
index.add_fixing(date, *fixing).expect("a published figure");
}
let nominal_ts: Handle<dyn YieldTermStructure> =
Handle::new(shared(FlatForward::with_rate(
evaluation_date(),
0.05,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>);
let built: Vec<Shared<ZeroCouponInflationSwapHelper>> = zc_data()
.iter()
.map(|(maturity, rate)| {
ZeroCouponInflationSwapHelper::new(
Handle::new(shared(SimpleQuote::new(Some(rate / 100.0))) as Shared<dyn Quote>),
observation_lag(),
*maturity,
calendar(),
BusinessDayConvention::ModifiedFollowing,
day_counter(),
&index,
CpiInterpolationType::Flat,
Pillar::LastRelevantDate,
Shared::clone(&settings),
)
.expect("a three-month lag covers UK RPI's availability")
})
.collect();
let helpers: Vec<Shared<dyn ZeroInflationHelper>> = built
.iter()
.map(|helper| Shared::clone(helper) as Shared<dyn ZeroInflationHelper>)
.collect();
let base_date = index.last_fixing_date().expect("the fixings are on record");
assert_eq!(
base_date,
Date::new(1, July, 2007),
"the base date is the period of the last published figure"
);
let curve = PiecewiseZeroInflationCurve::new(
evaluation_date(),
base_date,
Frequency::Monthly,
day_counter(),
helpers,
None,
)
.unwrap();
hz.link_to(Shared::clone(&curve) as Shared<dyn ZeroInflationTermStructure>);
Fixture {
settings,
index,
nominal_ts,
first_helper: Shared::clone(&built[0]),
curve,
_hz: hz,
}
}
#[test]
fn the_bootstrapped_curve_reprices_the_quoted_swaps_to_zero() {
let fixture = a_fixture();
assert_eq!(
fixture
.first_helper
.swap()
.as_ref()
.expect("the helper's swap builds")
.inflation_cash_flow()
.fixing_date(),
Date::new(13, May, 2008)
);
let curve = &fixture.curve;
assert_eq!(curve.dates().unwrap()[0], curve.base_date());
assert!(
curve.times().unwrap()[0] < 0.0,
"the base node precedes the reference date: {}",
curve.times().unwrap()[0]
);
let (mut worst_npv, mut worst_bps) = (0.0_f64, 0.0_f64);
for (maturity, rate) in zc_data() {
let mut swap = fixture.a_swap(maturity, rate / 100.0);
worst_npv = worst_npv.max(swap.npv().unwrap().abs());
let mut bumped = fixture.a_swap(maturity, rate / 100.0 + BASIS_POINT);
let expected = bumped.fixed_leg_npv().unwrap() - swap.fixed_leg_npv().unwrap();
worst_bps = worst_bps.max((swap.fixed_leg_bps().unwrap() - expected).abs());
}
println!("worst |NPV| {worst_npv:e}, worst fixed-leg BPS error {worst_bps:e}");
assert!(worst_npv < EPS, "worst |NPV| {worst_npv}");
assert!(worst_bps < EPS, "worst fixed-leg BPS error {worst_bps}");
}
const SEASONALITY_FACTORS: [Real; 12] = [
1.003245, 1.000000, 0.999715, 1.000495, 1.000929, 0.998687, 0.995949, 0.994682, 0.995949,
1.000519, 1.003705, 1.004186,
];
fn a_seasonality(curve: &PiecewiseZeroInflationCurve<Linear>) -> Shared<dyn Seasonality> {
let (_, next_base_date) =
inflation_period(curve.base_date(), Frequency::Monthly).expect("a monthly curve");
shared(
MultiplicativePriceSeasonality::new(
Date::new(31, January, next_base_date.year()),
Frequency::Monthly,
SEASONALITY_FACTORS.to_vec(),
)
.expect("twelve monthly factors"),
) as Shared<dyn Seasonality>
}
#[test]
fn a_seasonality_moves_the_forecast_and_the_curve_reprices_the_swaps_again() {
let fixture = a_fixture();
let curve = &fixture.curve;
let a_date = Date::new(1, August, 2012);
let (maturity, rate) = zc_data()[6];
let mut held = fixture.a_swap(maturity, rate / 100.0);
let held_npv = held.npv().unwrap();
let before = fixture.index.fixing(a_date, true).unwrap();
curve
.set_seasonality(Some(a_seasonality(curve.as_ref())))
.unwrap();
assert_ne!(
held.npv().unwrap(),
held_npv,
"an already-priced swap was never told the curve moved"
);
let after = fixture.index.fixing(a_date, true).unwrap();
println!("forecast at {a_date}: {before} -> {after}");
assert!(
(after - before).abs() > 1.0e-3,
"the seasonality never reached the forecast: {before} vs {after}"
);
assert!(curve.has_seasonality());
let mut worst_npv = 0.0_f64;
for (maturity, rate) in zc_data() {
let mut swap = fixture.a_swap(maturity, rate / 100.0);
worst_npv = worst_npv.max(swap.npv().unwrap().abs());
}
println!("worst |NPV| under seasonality {worst_npv:e}");
assert!(worst_npv < EPS, "worst |NPV| {worst_npv}");
}
#[test]
fn the_index_forecasts_off_the_bootstrapped_curve() {
let fixture = a_fixture();
let curve = &fixture.curve;
let schedule = MakeSchedule::new()
.from(TermStructure::reference_date(curve.as_ref()).unwrap())
.to(curve.max_date() - Period::new(1, TimeUnit::Months))
.with_tenor(Period::new(1, TimeUnit::Months))
.with_calendar(calendar())
.with_convention(BusinessDayConvention::ModifiedFollowing)
.build();
let base_date = curve.base_date();
let base_fixing = fixture.index.fixing(base_date, false).unwrap();
let curve_day_counter = curve.require_day_counter().unwrap();
let mut worst = 0.0_f64;
for &date in schedule.dates() {
let z = curve.zero_rate_date(date, false).unwrap();
let period_start = inflation_period(date, Frequency::Monthly).unwrap().0;
let t = curve_day_counter.year_fraction(base_date, period_start);
let calc = if t <= 0.0 {
fixture.index.fixing(date, false).unwrap()
} else {
base_fixing * (1.0 + z).powf(t)
};
let forecast = fixture.index.fixing(date, true).unwrap();
worst = worst.max((calc - forecast).abs());
}
println!(
"worst forecast error {worst:e} over {} dates",
schedule.len()
);
assert!(worst < EPS, "worst forecast error {worst}");
}
}