use crate::errors::QlResult;
use crate::math::interpolations::{Interpolation, Interpolator};
use crate::patterns::observable::{AsObservable, Observable};
use crate::require;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::termstructures::interpolatedcurve::InterpolatedCurve;
use crate::termstructures::volatility::VolatilityTermStructure;
use crate::termstructures::{TermStructure, TermStructureBase};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::types::{Natural, Rate, Real, Time, Volatility};
use super::{YoYOptionletVolatilitySurface, YoYOptionletVolatilitySurfaceBase};
pub(crate) fn extended_value<I: Interpolation>(interpolation: &I, t: Time) -> QlResult<Real> {
let x_min = interpolation.x_min();
let x_max = interpolation.x_max();
if t < x_min {
Ok(interpolation.value(x_min)? + interpolation.derivative(x_min)? * (t - x_min))
} else if t > x_max {
Ok(interpolation.value(x_max)? + interpolation.derivative(x_max)? * (t - x_max))
} else {
interpolation.value(t)
}
}
pub struct InterpolatedYoYOptionletVolatilityCurve<I: Interpolator> {
base: YoYOptionletVolatilitySurfaceBase,
curve: InterpolatedCurve<I>,
dates: Vec<Date>,
min_strike: Rate,
max_strike: Rate,
}
impl<I: Interpolator> InterpolatedYoYOptionletVolatilityCurve<I> {
#[allow(clippy::too_many_arguments)]
pub fn new(
settlement_days: Natural,
calendar: Calendar,
business_day_convention: BusinessDayConvention,
day_counter: DayCounter,
observation_lag: Period,
frequency: Frequency,
index_is_interpolated: bool,
dates: Vec<Date>,
volatilities: Vec<Volatility>,
min_strike: Rate,
max_strike: Rate,
interpolator: I,
settings: Shared<Settings<Date>>,
) -> QlResult<InterpolatedYoYOptionletVolatilityCurve<I>> {
require!(
dates.len() == volatilities.len(),
"must have same number of dates and vols: {} vs {}",
dates.len(),
volatilities.len()
);
require!(
dates.len() > 1,
"must have at least two dates: {}",
dates.len()
);
let base = YoYOptionletVolatilitySurfaceBase::new(
settlement_days,
calendar,
business_day_convention,
day_counter.clone(),
observation_lag,
frequency,
index_is_interpolated,
settings,
);
let reference = base.term_structure_base().reference_date()?;
let times = InterpolatedCurve::<I>::times_from_dates(&dates, reference, &day_counter)?;
let mut curve = InterpolatedCurve::new(times, volatilities, interpolator);
curve.setup_interpolation()?;
let base_time = day_counter.year_fraction(reference, base.base_date()?);
base.set_base_level(extended_value(curve.interpolation()?, base_time)?);
Ok(InterpolatedYoYOptionletVolatilityCurve {
base,
curve,
dates,
min_strike,
max_strike,
})
}
pub fn surface_base(&self) -> &YoYOptionletVolatilitySurfaceBase {
&self.base
}
pub fn times(&self) -> &[Time] {
self.curve.times()
}
pub fn dates(&self) -> &[Date] {
&self.dates
}
pub fn data(&self) -> &[Real] {
self.curve.data()
}
pub fn nodes(&self) -> Vec<(Date, Real)> {
self.dates
.iter()
.copied()
.zip(self.curve.data().iter().copied())
.collect()
}
fn last_date(&self) -> Date {
*self
.dates
.last()
.expect("construction rejected fewer than two dates")
}
fn volatility_impl(&self, t: Time) -> QlResult<Volatility> {
self.curve.interpolation()?.value(t)
}
}
impl<I: Interpolator> AsObservable for InterpolatedYoYOptionletVolatilityCurve<I> {
fn observable(&self) -> &Observable {
self.base.term_structure_base().observable()
}
}
impl<I: Interpolator> TermStructure for InterpolatedYoYOptionletVolatilityCurve<I> {
fn base(&self) -> &TermStructureBase {
self.base.term_structure_base()
}
fn max_date(&self) -> Date {
let t_max = *self
.curve
.times()
.last()
.expect("construction rejected fewer than two dates");
self.option_date_from_tenor(Period::new(t_max.ceil() as i32, TimeUnit::Years))
.unwrap_or_else(|_| self.last_date())
}
}
impl<I: Interpolator> VolatilityTermStructure for InterpolatedYoYOptionletVolatilityCurve<I> {
fn business_day_convention(&self) -> BusinessDayConvention {
self.base.business_day_convention()
}
fn min_strike(&self) -> Rate {
self.min_strike
}
fn max_strike(&self) -> Rate {
self.max_strike
}
}
impl<I: Interpolator> YoYOptionletVolatilitySurface for InterpolatedYoYOptionletVolatilityCurve<I> {
fn base_date(&self) -> QlResult<Date> {
self.base.base_date()
}
fn volatility(&self, date: Date, strike: Rate, obs_lag: Period) -> QlResult<Volatility> {
let observed = self.base.observed(date - obs_lag)?;
self.base.check_range(
observed,
strike,
self.min_strike,
self.max_strike,
TermStructure::max_date(self),
)?;
self.volatility_impl(self.time_from_reference(observed)?)
}
fn total_variance(&self, date: Date, strike: Rate, obs_lag: Period) -> QlResult<Real> {
let volatility = self.volatility(date, strike, obs_lag)?;
Ok(volatility * volatility * self.base.time_from_base(date, obs_lag)?)
}
fn base_level(&self) -> QlResult<Volatility> {
self.base.base_level()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::interpolations::linear::Linear;
use crate::shared::shared;
use crate::time::calendars::target::Target;
use crate::time::date::Month::{April, July, June, March, May};
use crate::time::daycounters::actual365fixed::Actual365Fixed;
fn settings() -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, June, 2026));
settings
}
fn build(
index_is_interpolated: bool,
dates: Vec<Date>,
volatilities: Vec<Volatility>,
) -> QlResult<InterpolatedYoYOptionletVolatilityCurve<Linear>> {
InterpolatedYoYOptionletVolatilityCurve::new(
0,
Target::new(),
BusinessDayConvention::ModifiedFollowing,
Actual365Fixed::new(),
Period::new(2, TimeUnit::Months),
Frequency::Monthly,
index_is_interpolated,
dates,
volatilities,
-1.0,
3.0,
Linear,
settings(),
)
}
fn sample_dates() -> Vec<Date> {
vec![
Date::new(1, April, 2026),
Date::new(1, April, 2027),
Date::new(1, April, 2028),
]
}
fn sample_vols() -> Vec<Volatility> {
vec![0.010, 0.014, 0.012]
}
fn sample() -> InterpolatedYoYOptionletVolatilityCurve<Linear> {
build(false, sample_dates(), sample_vols()).unwrap()
}
#[test]
fn the_base_date_snaps_to_the_publication_period_unless_interpolated() {
assert_eq!(sample().base_date().unwrap(), Date::new(1, April, 2026));
let interpolated = build(true, sample_dates(), sample_vols()).unwrap();
assert_eq!(
YoYOptionletVolatilitySurface::base_date(&interpolated).unwrap(),
Date::new(15, April, 2026)
);
}
#[test]
fn the_base_level_reads_the_interpolation_at_the_base_time() {
let curve = sample();
assert_eq!(curve.base_level().unwrap(), 0.010);
let shifted = build(
false,
vec![
Date::new(1, May, 2026),
Date::new(1, April, 2027),
Date::new(1, April, 2028),
],
sample_vols(),
)
.unwrap();
let times = shifted.times().to_vec();
let base_time = shifted
.time_from_reference(Date::new(1, April, 2026))
.unwrap();
let slope = (0.014 - 0.010) / (times[1] - times[0]);
let expected = 0.010 + slope * (base_time - times[0]);
let level = shifted.base_level().unwrap();
assert!(
(level - expected).abs() < 1e-15,
"extended base level was {level}, expected {expected}"
);
}
#[test]
fn the_curve_interpolates_in_time_and_ignores_the_strike() {
let curve = sample();
let zero_lag = Period::new(0, TimeUnit::Days);
for (date, vol) in curve.nodes() {
for strike in [-0.5, 0.0, 0.02] {
let read = curve.volatility(date, strike, zero_lag).unwrap();
assert!((read - vol).abs() < 1e-15, "node {date} read {read}");
}
}
let times = curve.times().to_vec();
let t = curve.time_from_reference(Date::new(1, July, 2027)).unwrap();
let expected = 0.014 + (0.012 - 0.014) * (t - times[1]) / (times[2] - times[1]);
let read = curve
.volatility(Date::new(20, July, 2027), 0.02, zero_lag)
.unwrap();
assert!(
(read - expected).abs() < 1e-15,
"mid-period read {read}, expected {expected}"
);
}
#[test]
fn the_total_variance_accrues_from_the_base_date() {
let curve = sample();
let zero_lag = Period::new(0, TimeUnit::Days);
let exercise = Date::new(20, July, 2027);
let vol = curve.volatility(exercise, 0.02, zero_lag).unwrap();
let time = Actual365Fixed::new()
.year_fraction(Date::new(1, April, 2026), Date::new(1, July, 2027));
let variance = curve.total_variance(exercise, 0.02, zero_lag).unwrap();
assert!(
(variance - vol * vol * time).abs() < 1e-18,
"variance was {variance}"
);
}
#[test]
fn construction_and_queries_reject_inconsistent_input() {
let err = match build(false, sample_dates(), vec![0.01, 0.014]) {
Ok(_) => panic!("expected a construction error"),
Err(err) => err,
};
assert!(err.message().contains("same number of dates and vols"));
let err = match build(false, vec![Date::new(1, April, 2026)], vec![0.01]) {
Ok(_) => panic!("expected a construction error"),
Err(err) => err,
};
assert!(err.message().contains("at least two dates"));
let curve = sample();
let zero_lag = Period::new(0, TimeUnit::Days);
let early = curve
.volatility(Date::new(20, March, 2026), 0.02, zero_lag)
.expect_err("March 2026 precedes the April base date");
assert!(early.message().contains("before base date"), "err: {early}");
let wide = curve
.volatility(Date::new(20, July, 2027), 5.0, zero_lag)
.expect_err("5.0 is past the 3.0 maximum strike");
assert!(wide.message().contains("outside the curve"), "err: {wide}");
}
}