use crate::errors::QlResult;
use crate::handle::Handle;
use crate::interestrate::{Compounding, InterestRate};
use crate::patterns::observable::Observer;
use crate::quotes::Quote;
use crate::require;
use crate::shared::SharedMut;
use crate::termstructures::{TermStructure, TermStructureBase};
use crate::time::date::{Date, Month};
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::types::{DiscountFactor, Time};
const DT: Time = 0.0001;
pub struct YieldJumps {
quotes: Vec<Handle<dyn Quote>>,
dates: Vec<Date>,
}
impl YieldJumps {
pub fn new(
base: &TermStructureBase,
quotes: Vec<Handle<dyn Quote>>,
dates: Vec<Date>,
) -> QlResult<Self> {
Self::with_observer(base, &base.updater(), quotes, dates)
}
pub fn with_observer(
base: &TermStructureBase,
observer: &SharedMut<dyn Observer>,
quotes: Vec<Handle<dyn Quote>>,
mut dates: Vec<Date>,
) -> QlResult<Self> {
if dates.is_empty() && !quotes.is_empty() {
let year = base.reference_date()?.year();
require!(
quotes.len() <= (Date::max_date().year() - year + 1) as usize,
"automatic jump dates exceed the maximum supported date"
);
dates = (0..quotes.len())
.map(|i| Date::new(31, Month::December, year + i as i32))
.collect();
}
require!(
quotes.len() == dates.len(),
"mismatch between number of jumps ({}) and jump dates ({})",
quotes.len(),
dates.len()
);
require!(
dates.iter().all(|date| *date != Date::null()),
"null jump date"
);
let jumps = Self { quotes, dates };
jumps.register_observer(observer);
Ok(jumps)
}
pub fn dates(&self) -> &[Date] {
&self.dates
}
pub fn register_observer(&self, observer: &SharedMut<dyn Observer>) {
for quote in &self.quotes {
quote.register_observer(observer);
}
}
fn factor<T: TermStructure + ?Sized>(&self, curve: &T, t: Time) -> QlResult<DiscountFactor> {
let mut factor = 1.0;
for (i, (date, handle)) in self.dates.iter().zip(&self.quotes).enumerate() {
let jump_time = curve.time_from_reference(*date)?;
if jump_time > 0.0 && jump_time < t {
let quote = handle.current_link()?;
require!(quote.is_valid(), "invalid jump quote #{}", i + 1);
let value = quote.value()?;
if value.is_nan() || value <= 0.0 {
crate::fail!("invalid jump value #{}: {value}", i + 1);
}
factor *= value;
}
}
Ok(factor)
}
}
pub trait YieldTermStructure: TermStructure {
fn discount_impl(&self, t: Time) -> QlResult<DiscountFactor>;
fn as_any(&self) -> Option<&dyn std::any::Any> {
None
}
fn jumps(&self) -> Option<&YieldJumps> {
None
}
fn jump_dates(&self) -> &[Date] {
self.jumps().map_or(&[], YieldJumps::dates)
}
fn jump_times(&self) -> QlResult<Vec<Time>> {
self.jump_dates()
.iter()
.map(|date| self.time_from_reference(*date))
.collect()
}
fn jump_factor(&self, t: Time) -> QlResult<DiscountFactor> {
match self.jumps() {
Some(jumps) => jumps.factor(self, t),
None => Ok(1.0),
}
}
fn discount_date(&self, date: Date, extrapolate: bool) -> QlResult<DiscountFactor> {
self.discount(self.time_from_reference(date)?, extrapolate)
}
fn discount(&self, t: Time, extrapolate: bool) -> QlResult<DiscountFactor> {
self.check_range_time(t, extrapolate)?;
Ok(self.jump_factor(t)? * self.discount_impl(t)?)
}
fn zero_rate_date(
&self,
date: Date,
result_day_counter: DayCounter,
comp: Compounding,
freq: Frequency,
extrapolate: bool,
) -> QlResult<InterestRate> {
let t = self.time_from_reference(date)?;
if t == 0.0 {
let compound = 1.0 / self.discount(DT, extrapolate)?;
return InterestRate::implied_rate(compound, result_day_counter, comp, freq, DT);
}
let compound = 1.0 / self.discount(t, extrapolate)?;
InterestRate::implied_rate_between(
compound,
result_day_counter,
comp,
freq,
self.reference_date()?,
date,
)
}
fn zero_rate(
&self,
t: Time,
comp: Compounding,
freq: Frequency,
extrapolate: bool,
) -> QlResult<InterestRate> {
let day_counter = self.require_day_counter()?;
let t = if t == 0.0 { DT } else { t };
let compound = 1.0 / self.discount(t, extrapolate)?;
InterestRate::implied_rate(compound, day_counter, comp, freq, t)
}
fn forward_rate_between(
&self,
d1: Date,
d2: Date,
result_day_counter: DayCounter,
comp: Compounding,
freq: Frequency,
extrapolate: bool,
) -> QlResult<InterestRate> {
if d1 == d2 {
self.check_range_date(d1, extrapolate)?;
let t1 = Time::max(self.time_from_reference(d1)? - DT / 2.0, 0.0);
let t2 = t1 + DT;
let compound = self.discount(t1, true)? / self.discount(t2, true)?;
return InterestRate::implied_rate(compound, result_day_counter, comp, freq, DT);
}
require!(d1 < d2, "{d1} later than {d2}");
let compound =
self.discount_date(d1, extrapolate)? / self.discount_date(d2, extrapolate)?;
InterestRate::implied_rate_between(compound, result_day_counter, comp, freq, d1, d2)
}
fn forward_rate_period(
&self,
date: Date,
p: Period,
result_day_counter: DayCounter,
comp: Compounding,
freq: Frequency,
extrapolate: bool,
) -> QlResult<InterestRate> {
self.forward_rate_between(date, date + p, result_day_counter, comp, freq, extrapolate)
}
fn forward_rate(
&self,
t1: Time,
t2: Time,
comp: Compounding,
freq: Frequency,
extrapolate: bool,
) -> QlResult<InterestRate> {
let day_counter = self.require_day_counter()?;
let (t1, t2, compound) = if t2 == t1 {
self.check_range_time(t1, extrapolate)?;
let t1 = Time::max(t1 - DT / 2.0, 0.0);
let t2 = t1 + DT;
let compound = self.discount(t1, true)? / self.discount(t2, true)?;
(t1, t2, compound)
} else {
if t1.is_nan() || t2.is_nan() || t2 <= t1 {
crate::fail!("t1 ({t1}) >= t2 ({t2})");
}
let compound = self.discount(t1, extrapolate)? / self.discount(t2, extrapolate)?;
(t1, t2, compound)
};
InterestRate::implied_rate(compound, day_counter, comp, freq, t2 - t1)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patterns::observable::{AsObservable, Observable};
use crate::quotes::SimpleQuote;
use crate::settings::Settings;
use crate::shared::{Shared, shared};
use crate::test_support::{Flag, as_observer};
use crate::time::calendars::nullcalendar::NullCalendar;
use crate::time::daycounters::actual360::Actual360;
use crate::time::timeunit::TimeUnit;
use crate::types::Real;
struct ExponentialCurve {
base: TermStructureBase,
rate: Real,
jumps: Option<YieldJumps>,
}
impl ExponentialCurve {
fn new(reference: Date, rate: Real) -> ExponentialCurve {
ExponentialCurve {
base: TermStructureBase::with_reference_date(
reference,
None,
Some(Actual360::new()),
),
rate,
jumps: None,
}
}
}
impl AsObservable for ExponentialCurve {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl TermStructure for ExponentialCurve {
fn base(&self) -> &TermStructureBase {
&self.base
}
fn max_date(&self) -> Date {
Date::max_date()
}
}
impl YieldTermStructure for ExponentialCurve {
fn discount_impl(&self, t: Time) -> QlResult<DiscountFactor> {
Ok((-self.rate * t).exp())
}
fn jumps(&self) -> Option<&YieldJumps> {
self.jumps.as_ref()
}
}
fn curve() -> ExponentialCurve {
ExponentialCurve::new(Date::new(15, Month::June, 2026), 0.05)
}
#[test]
fn discount_checks_range_then_delegates() {
let curve = curve();
let df = curve.discount(2.0, false).unwrap();
assert!((df - (-0.1_f64).exp()).abs() < 1.0e-15);
assert!(curve.discount(-0.5, false).is_err());
assert_eq!(curve.discount(0.0, false).unwrap(), 1.0);
}
#[test]
fn discount_date_converts_through_the_day_counter() {
let curve = curve();
let reference = curve.reference_date().unwrap();
let df = curve.discount_date(reference + 180, false).unwrap();
assert!((df - (-0.05_f64 * 0.5).exp()).abs() < 1.0e-15);
}
#[test]
fn zero_rate_recovers_the_continuous_rate() {
let curve = curve();
let zero = curve
.zero_rate(1.5, Compounding::Continuous, Frequency::Annual, false)
.unwrap();
assert!((zero.rate() - 0.05).abs() < 1.0e-12);
let zero = curve
.zero_rate(0.0, Compounding::Continuous, Frequency::Annual, false)
.unwrap();
assert!((zero.rate() - 0.05).abs() < 1.0e-12);
}
#[test]
fn zero_rate_date_round_trips_the_discount() {
let curve = curve();
let reference = curve.reference_date().unwrap();
let date = reference + 360;
let zero = curve
.zero_rate_date(
date,
Actual360::new(),
Compounding::Compounded,
Frequency::Semiannual,
false,
)
.unwrap();
let df = curve.discount_date(date, false).unwrap();
let round_trip = zero.discount_factor_between(reference, date).unwrap();
assert!((round_trip - df).abs() < 1.0e-15);
let at_reference = curve
.zero_rate_date(
reference,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
false,
)
.unwrap();
assert!((at_reference.rate() - 0.05).abs() < 1.0e-10);
}
#[test]
fn forward_rate_matches_the_flat_rate() {
let curve = curve();
let forward = curve
.forward_rate(0.5, 1.5, Compounding::Continuous, Frequency::Annual, false)
.unwrap();
assert!((forward.rate() - 0.05).abs() < 1.0e-12);
let instantaneous = curve
.forward_rate(1.0, 1.0, Compounding::Continuous, Frequency::Annual, false)
.unwrap();
assert!((instantaneous.rate() - 0.05).abs() < 1.0e-9);
assert!(
curve
.forward_rate(1.5, 0.5, Compounding::Continuous, Frequency::Annual, false)
.is_err()
);
}
#[test]
fn forward_rate_between_dates_and_periods_agree() {
let curve = curve();
let reference = curve.reference_date().unwrap();
let d1 = reference + 90;
let d2 = reference + 270;
let between = curve
.forward_rate_between(
d1,
d2,
Actual360::new(),
Compounding::Simple,
Frequency::Annual,
false,
)
.unwrap();
let by_period = curve
.forward_rate_period(
d1,
Period::new(180, TimeUnit::Days),
Actual360::new(),
Compounding::Simple,
Frequency::Annual,
false,
)
.unwrap();
assert_eq!(between.rate(), by_period.rate());
let df1 = curve.discount_date(d1, false).unwrap();
let df2 = curve.discount_date(d2, false).unwrap();
let implied = between.compound_factor_between(d1, d2).unwrap();
assert!((implied - df1 / df2).abs() < 1.0e-15);
assert!(
curve
.forward_rate_between(
d2,
d1,
Actual360::new(),
Compounding::Simple,
Frequency::Annual,
false,
)
.is_err()
);
let instantaneous = curve
.forward_rate_between(
d1,
d1,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
false,
)
.unwrap();
assert!((instantaneous.rate() - 0.05).abs() < 1.0e-9);
}
fn quote(value: Real) -> Handle<dyn Quote> {
Handle::new(shared(SimpleQuote::new(value)) as Shared<dyn Quote>)
}
fn jumped(jumps: Vec<Handle<dyn Quote>>, dates: Vec<Date>) -> QlResult<ExponentialCurve> {
let mut curve = curve();
curve.jumps = Some(YieldJumps::new(&curve.base, jumps, dates)?);
Ok(curve)
}
#[test]
fn discount_multiplies_every_jump_strictly_inside_the_interval() {
let reference = Date::new(15, Month::June, 2026);
let dates = vec![reference + 90, reference + 180];
let curve = jumped(vec![quote(0.99), quote(0.98)], dates.clone()).unwrap();
assert_eq!(curve.jump_dates(), dates);
assert_eq!(curve.jump_times().unwrap(), vec![0.25, 0.5]);
for (days, factor) in [
(0, 1.0),
(89, 1.0),
(90, 1.0),
(91, 0.99),
(180, 0.99),
(181, 0.99 * 0.98),
(720, 0.99 * 0.98),
] {
let t = days as Real / 360.0;
let expected = factor * (-0.05 * t).exp();
assert_eq!(curve.jump_factor(t).unwrap(), factor);
assert!((curve.discount(t, false).unwrap() - expected).abs() < 1.0e-15);
let df = curve.discount_date(reference + days, false).unwrap();
assert!((df - expected).abs() < 1.0e-15);
}
let zero = curve
.zero_rate(1.0, Compounding::Continuous, Frequency::Annual, false)
.unwrap();
let expected = 0.05 - (0.99_f64 * 0.98).ln();
assert!((zero.rate() - expected).abs() < 1.0e-14);
}
#[test]
fn out_of_order_jumps_are_all_counted_without_a_prefix_break() {
let reference = Date::new(15, Month::June, 2026);
let curve = jumped(
vec![quote(0.97), quote(0.99)],
vec![reference + 180, reference + 90],
)
.unwrap();
assert_eq!(curve.jump_factor(0.4).unwrap(), 0.99);
assert_eq!(curve.jump_factor(0.6).unwrap(), 0.99 * 0.97);
}
#[test]
fn jumps_on_or_before_the_reference_date_are_skipped() {
let reference = Date::new(15, Month::June, 2026);
let curve = jumped(
vec![quote(0.5), quote(-1.0), quote(0.99)],
vec![reference, reference - 30, reference + 90],
)
.unwrap();
assert_eq!(curve.jump_factor(1.0).unwrap(), 0.99);
assert!(curve.discount(-0.5, false).is_err());
}
#[test]
fn invalid_jumps_are_checked_only_after_their_boundary() {
let date = Date::new(15, Month::June, 2026) + 360;
for value in [0.0, -0.1, f64::NAN] {
let curve = jumped(vec![quote(value)], vec![date]).unwrap();
assert!(curve.discount(1.0, false).is_ok());
assert!(curve.discount(1.01, false).is_err());
}
for handle in [
Handle::empty(),
Handle::new(shared(SimpleQuote::default()) as Shared<dyn Quote>),
] {
let curve = jumped(vec![handle], vec![date]).unwrap();
assert!(curve.discount(1.0, false).is_ok());
assert!(curve.discount(1.01, false).is_err());
}
let above_one = jumped(vec![quote(1.02)], vec![date]).unwrap();
let expected = 1.02 * (-0.05_f64 * 1.5).exp();
assert!((above_one.discount(1.5, false).unwrap() - expected).abs() < 1.0e-15);
assert!(jumped(vec![quote(0.9)], vec![date, date + 1]).is_err());
assert!(jumped(vec![], vec![date]).is_err());
assert!(jumped(vec![quote(0.9)], vec![Date::null()]).is_err());
assert!(curve().jumps().is_none());
assert_eq!(curve().jump_factor(5.0).unwrap(), 1.0);
}
#[test]
fn empty_dates_generate_consecutive_year_ends() {
let curve = jumped(vec![quote(0.99), quote(0.98), quote(0.97)], vec![]).unwrap();
assert_eq!(
curve.jump_dates(),
[
Date::new(31, Month::December, 2026),
Date::new(31, Month::December, 2027),
Date::new(31, Month::December, 2028),
]
);
}
#[test]
fn jump_quote_change_notifies_the_curve_observers() {
let jump = shared(SimpleQuote::new(0.99));
let curve = jumped(
vec![Handle::new(jump.clone() as Shared<dyn Quote>)],
vec![Date::new(15, Month::June, 2026) + 90],
)
.unwrap();
let flag = Flag::new();
curve.observable().register_observer(&as_observer(&flag));
jump.set_value(0.95);
assert!(Flag::is_up(&flag));
let expected = 0.95 * (-0.05_f64).exp();
assert!((curve.discount(1.0, false).unwrap() - expected).abs() < 1.0e-15);
}
#[test]
fn moving_jump_times_follow_the_reference_date_while_generated_dates_stay() {
let settings = shared(Settings::new());
settings.set_evaluation_date(Date::new(15, Month::June, 2026));
let base = TermStructureBase::moving(
0,
NullCalendar::new(),
Some(Actual360::new()),
settings.clone(),
);
let jumps = YieldJumps::new(&base, vec![quote(0.99), quote(0.98)], vec![]).unwrap();
let curve = ExponentialCurve {
base,
rate: 0.05,
jumps: Some(jumps),
};
let dates = [
Date::new(31, Month::December, 2026),
Date::new(31, Month::December, 2027),
];
assert_eq!(curve.jump_dates(), dates);
assert_eq!(
curve.jump_times().unwrap(),
vec![199.0 / 360.0, 564.0 / 360.0]
);
assert_eq!(curve.jump_factor(1.0).unwrap(), 0.99);
let flag = Flag::new();
curve.observable().register_observer(&as_observer(&flag));
settings.set_evaluation_date(Date::new(2, Month::January, 2027));
assert!(Flag::is_up(&flag));
assert_eq!(curve.jump_dates(), dates);
assert_eq!(
curve.jump_times().unwrap(),
vec![-2.0 / 360.0, 363.0 / 360.0]
);
assert_eq!(curve.jump_factor(1.1).unwrap(), 0.98);
}
}