use std::cell::RefCell;
use crate::errors::QlResult;
use crate::indexes::inflationindex::inflation_period;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::termstructures::inflation::seasonality::Seasonality;
use crate::termstructures::{TermStructure, TermStructureBase};
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::types::{Natural, Rate, Time};
use crate::{fail, require};
pub struct InflationTermStructureBase {
base: TermStructureBase,
frequency: Frequency,
base_rate: Option<Rate>,
base_date: Date,
seasonality: RefCell<Option<Shared<dyn Seasonality>>>,
}
impl InflationTermStructureBase {
pub fn new(
base_date: Date,
frequency: Frequency,
day_counter: Option<DayCounter>,
base_rate: Option<Rate>,
seasonality: Option<Shared<dyn Seasonality>>,
) -> InflationTermStructureBase {
InflationTermStructureBase {
base: TermStructureBase::new(day_counter),
frequency,
base_rate,
base_date,
seasonality: RefCell::new(seasonality),
}
}
pub fn with_reference_date(
reference_date: Date,
base_date: Date,
frequency: Frequency,
day_counter: Option<DayCounter>,
base_rate: Option<Rate>,
seasonality: Option<Shared<dyn Seasonality>>,
) -> InflationTermStructureBase {
InflationTermStructureBase {
base: TermStructureBase::with_reference_date(reference_date, None, day_counter),
frequency,
base_rate,
base_date,
seasonality: RefCell::new(seasonality),
}
}
#[allow(clippy::too_many_arguments)]
pub fn moving(
settlement_days: Natural,
calendar: Calendar,
base_date: Date,
frequency: Frequency,
day_counter: Option<DayCounter>,
base_rate: Option<Rate>,
seasonality: Option<Shared<dyn Seasonality>>,
settings: Shared<Settings<Date>>,
) -> InflationTermStructureBase {
InflationTermStructureBase {
base: TermStructureBase::moving(settlement_days, calendar, day_counter, settings),
frequency,
base_rate,
base_date,
seasonality: RefCell::new(seasonality),
}
}
pub fn term_structure_base(&self) -> &TermStructureBase {
&self.base
}
}
pub trait InflationTermStructure: TermStructure {
fn inflation_base(&self) -> &InflationTermStructureBase;
fn as_inflation_term_structure(&self) -> &dyn InflationTermStructure;
fn frequency(&self) -> Frequency {
self.inflation_base().frequency
}
fn base_date(&self) -> Date {
self.inflation_base().base_date
}
fn base_rate(&self) -> QlResult<Rate> {
match self.inflation_base().base_rate {
Some(rate) => Ok(rate),
None => fail!("base rate not available"),
}
}
fn seasonality(&self) -> Option<Shared<dyn Seasonality>> {
self.inflation_base().seasonality.borrow().clone()
}
fn has_seasonality(&self) -> bool {
self.inflation_base().seasonality.borrow().is_some()
}
fn set_seasonality(&self, seasonality: Option<Shared<dyn Seasonality>>) -> QlResult<()> {
*self.inflation_base().seasonality.borrow_mut() = seasonality;
self.check_seasonality()?;
self.update_after_seasonality_change();
Ok(())
}
fn check_seasonality(&self) -> QlResult<()> {
if let Some(seasonality) = self.seasonality() {
require!(
seasonality.is_consistent(self.as_inflation_term_structure())?,
"Seasonality inconsistent with inflation term structure"
);
}
Ok(())
}
fn update_after_seasonality_change(&self) {
self.base().updater().borrow_mut().update();
}
fn check_inflation_range_date(&self, date: Date, extrapolate: bool) -> QlResult<()> {
let base_date = self.base_date();
require!(
date >= base_date,
"date ({date}) is before base date ({base_date})"
);
require!(
extrapolate || self.allows_extrapolation() || date <= self.max_date(),
"date ({date}) is past max curve date ({max})",
max = self.max_date()
);
Ok(())
}
fn check_inflation_range_time(&self, t: Time, extrapolate: bool) -> QlResult<()> {
let base_time = self.time_from_reference(self.base_date())?;
if t < base_time || t.is_nan() {
fail!("time ({t}) is before base date");
}
if extrapolate || self.allows_extrapolation() {
return Ok(());
}
let max_time = self.max_time()?;
if t > max_time {
fail!("time ({t}) is past max curve time ({max_time})");
}
Ok(())
}
}
pub trait ZeroInflationTermStructure: InflationTermStructure {
fn zero_rate_impl(&self, t: Time) -> QlResult<Rate>;
fn zero_rate_date(&self, date: Date, extrapolate: bool) -> QlResult<Rate> {
let (period_start, _) = inflation_period(date, self.frequency())?;
self.check_inflation_range_date(period_start, extrapolate)?;
let t = self.time_from_reference(period_start)?;
let zero_rate = self.zero_rate_impl(t)?;
match self.seasonality() {
Some(seasonality) => {
seasonality.correct_zero_rate(date, zero_rate, self.as_inflation_term_structure())
}
None => Ok(zero_rate),
}
}
fn zero_rate(&self, t: Time, extrapolate: bool) -> QlResult<Rate> {
self.check_inflation_range_time(t, extrapolate)?;
self.zero_rate_impl(t)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patterns::observable::{AsObservable, Observable};
use crate::shared::shared;
use crate::termstructures::inflation::seasonality::MultiplicativePriceSeasonality;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
struct TestCurve {
inflation: InflationTermStructureBase,
max: Date,
}
fn reference() -> Date {
Date::new(15, Month::January, 2026)
}
fn base_date() -> Date {
Date::new(1, Month::December, 2025)
}
fn curve_with(frequency: Frequency, base_rate: Option<Rate>) -> TestCurve {
TestCurve {
inflation: InflationTermStructureBase::with_reference_date(
reference(),
base_date(),
frequency,
Some(Actual360::new()),
base_rate,
None,
),
max: Date::new(15, Month::January, 2036),
}
}
fn curve(base_rate: Option<Rate>) -> TestCurve {
curve_with(Frequency::Monthly, base_rate)
}
impl AsObservable for TestCurve {
fn observable(&self) -> &Observable {
self.inflation.term_structure_base().observable()
}
}
impl TermStructure for TestCurve {
fn base(&self) -> &TermStructureBase {
self.inflation.term_structure_base()
}
fn max_date(&self) -> Date {
self.max
}
}
impl InflationTermStructure for TestCurve {
fn inflation_base(&self) -> &InflationTermStructureBase {
&self.inflation
}
fn as_inflation_term_structure(&self) -> &dyn InflationTermStructure {
self
}
}
impl ZeroInflationTermStructure for TestCurve {
fn zero_rate_impl(&self, t: Time) -> QlResult<Rate> {
Ok(t)
}
}
#[test]
fn date_range_check_bounds_on_the_base_date_not_the_reference_date() {
let curve = curve(None);
let between = Date::new(15, Month::December, 2025);
assert!(curve.check_inflation_range_date(base_date(), false).is_ok());
assert!(curve.check_inflation_range_date(between, false).is_ok());
assert!(TermStructure::check_range_date(&curve, between, false).is_err());
let before = curve
.check_inflation_range_date(base_date() - 1, false)
.unwrap_err();
assert!(before.message().contains("is before base date"));
}
#[test]
fn date_range_check_enforces_the_max_date_unless_extrapolating() {
let curve = curve(None);
assert!(curve.check_inflation_range_date(curve.max, false).is_ok());
let past = curve
.check_inflation_range_date(curve.max + 1, false)
.unwrap_err();
assert!(past.message().contains("past max curve date"));
assert!(
curve
.check_inflation_range_date(curve.max + 1, true)
.is_ok()
);
curve.enable_extrapolation();
assert!(
curve
.check_inflation_range_date(curve.max + 1, false)
.is_ok()
);
}
#[test]
fn time_range_check_bounds_on_the_negative_base_date_time() {
let curve = curve(None);
let base_time = curve.time_from_reference(base_date()).unwrap();
assert_eq!(base_time, -0.125);
assert!(curve.check_inflation_range_time(base_time, false).is_ok());
assert!(curve.check_inflation_range_time(-0.1, false).is_ok());
assert!(TermStructure::check_range_time(&curve, base_time, false).is_err());
let before = curve
.check_inflation_range_time(base_time - 0.001, false)
.unwrap_err();
assert!(before.message().contains("is before base date"));
assert!(curve.check_inflation_range_time(Time::NAN, true).is_err());
let past = curve
.check_inflation_range_time(curve.max_time().unwrap() + 1.0, false)
.unwrap_err();
assert!(past.message().contains("past max curve time"));
}
#[test]
fn zero_rate_date_quantizes_to_the_start_of_the_inflation_period() {
let curve = curve(None);
let mid_month = Date::new(15, Month::March, 2026);
let period_start = Date::new(1, Month::March, 2026);
let quantized = curve.time_from_reference(period_start).unwrap();
let unquantized = curve.time_from_reference(mid_month).unwrap();
assert_ne!(quantized, unquantized);
assert_eq!(curve.zero_rate_date(mid_month, false).unwrap(), quantized);
assert_eq!(quantized, 0.125);
}
#[test]
fn zero_rate_date_quantizes_to_the_curve_frequency_not_to_the_month() {
let curve = curve_with(Frequency::Quarterly, None);
let quarter_start = Date::new(1, Month::January, 2026);
let rate = curve
.zero_rate_date(Date::new(15, Month::March, 2026), false)
.unwrap();
assert_eq!(rate, curve.time_from_reference(quarter_start).unwrap());
assert!(rate < 0.0);
}
#[test]
fn zero_rate_date_reaches_dates_between_the_base_and_reference_dates() {
let curve = curve(None);
let rate = curve
.zero_rate_date(Date::new(15, Month::December, 2025), false)
.unwrap();
assert_eq!(rate, curve.time_from_reference(base_date()).unwrap());
}
#[test]
fn zero_rate_passes_the_time_through_unquantized() {
let curve = curve(None);
assert_eq!(curve.zero_rate(0.375, false).unwrap(), 0.375);
assert_eq!(curve.zero_rate(-0.1, false).unwrap(), -0.1);
}
fn a_seasonality(count: usize) -> Shared<dyn Seasonality> {
shared(
MultiplicativePriceSeasonality::new(
Date::new(31, Month::January, 2026),
Frequency::Monthly,
(0..count).map(|i| 1.0 + i as Rate / 1000.0).collect(),
)
.expect("a whole multiple of twelve factors"),
) as Shared<dyn Seasonality>
}
#[test]
fn only_the_date_query_folds_the_seasonality_and_clearing_it_undoes_that() {
let curve = curve(None);
let date = Date::new(15, Month::March, 2026);
let raw = curve.zero_rate_date(date, false).unwrap();
let seasonality = a_seasonality(12);
curve
.set_seasonality(Some(Shared::clone(&seasonality)))
.unwrap();
assert!(curve.has_seasonality());
let corrected = curve.zero_rate_date(date, false).unwrap();
assert_eq!(
corrected,
seasonality.correct_zero_rate(date, raw, &curve).unwrap()
);
assert_ne!(corrected, raw, "the fold must move the rate");
assert_eq!(
curve.zero_rate(raw, false).unwrap(),
raw,
"the time query stays raw"
);
curve.set_seasonality(None).unwrap();
assert!(!curve.has_seasonality());
assert_eq!(curve.zero_rate_date(date, false).unwrap(), raw);
}
#[test]
fn an_inconsistent_seasonality_is_reported_by_the_gate() {
let curve = curve(None);
let err = curve.set_seasonality(Some(a_seasonality(24))).unwrap_err();
assert!(err.message().contains("#807"), "{}", err.message());
assert!(curve.has_seasonality(), "C++ stores before it checks");
assert!(curve.check_seasonality().is_err());
}
#[test]
fn base_rate_is_available_only_when_the_curve_carries_one() {
let zero = curve(None);
let err = zero.base_rate().unwrap_err();
assert!(err.message().contains("base rate not available"));
assert_eq!(curve(Some(0.02)).base_rate().unwrap(), 0.02);
}
#[test]
fn inspectors_report_the_constructor_arguments() {
let curve = curve(None);
assert_eq!(curve.frequency(), Frequency::Monthly);
assert_eq!(curve.base_date(), base_date());
assert_eq!(curve.reference_date().unwrap(), reference());
}
}