use crate::errors::QlResult;
use crate::handle::Handle;
use crate::indexes::inflationindex::inflation_period;
use crate::patterns::observable::{AsObservable, Observable};
use crate::quotes::{Quote, make_quote_handle};
use crate::require;
use crate::settings::Settings;
use crate::shared::Shared;
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::types::{Natural, Rate, Real, Time, Volatility};
use super::YoYOptionletVolatilitySurface;
pub struct ConstantYoYOptionletVolatility {
base: TermStructureBase,
business_day_convention: BusinessDayConvention,
volatility: Handle<dyn Quote>,
observation_lag: Period,
frequency: Frequency,
index_is_interpolated: bool,
min_strike: Rate,
max_strike: Rate,
}
impl ConstantYoYOptionletVolatility {
#[allow(clippy::too_many_arguments)]
fn assemble(
base: TermStructureBase,
business_day_convention: BusinessDayConvention,
volatility: Handle<dyn Quote>,
observation_lag: Period,
frequency: Frequency,
index_is_interpolated: bool,
min_strike: Rate,
max_strike: Rate,
observe: bool,
) -> ConstantYoYOptionletVolatility {
if observe {
volatility.register_observer(&base.updater());
}
ConstantYoYOptionletVolatility {
base,
business_day_convention,
volatility,
observation_lag,
frequency,
index_is_interpolated,
min_strike,
max_strike,
}
}
#[allow(clippy::too_many_arguments)]
pub fn new(
volatility: Volatility,
settlement_days: Natural,
calendar: Calendar,
business_day_convention: BusinessDayConvention,
day_counter: DayCounter,
observation_lag: Period,
frequency: Frequency,
index_is_interpolated: bool,
min_strike: Rate,
max_strike: Rate,
settings: Shared<Settings<Date>>,
) -> ConstantYoYOptionletVolatility {
Self::assemble(
TermStructureBase::moving(settlement_days, calendar, Some(day_counter), settings),
business_day_convention,
make_quote_handle(volatility).handle(),
observation_lag,
frequency,
index_is_interpolated,
min_strike,
max_strike,
false,
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_quote(
volatility: Handle<dyn Quote>,
settlement_days: Natural,
calendar: Calendar,
business_day_convention: BusinessDayConvention,
day_counter: DayCounter,
observation_lag: Period,
frequency: Frequency,
index_is_interpolated: bool,
min_strike: Rate,
max_strike: Rate,
settings: Shared<Settings<Date>>,
) -> ConstantYoYOptionletVolatility {
Self::assemble(
TermStructureBase::moving(settlement_days, calendar, Some(day_counter), settings),
business_day_convention,
volatility,
observation_lag,
frequency,
index_is_interpolated,
min_strike,
max_strike,
true,
)
}
pub fn observation_lag(&self) -> Period {
self.observation_lag
}
pub fn frequency(&self) -> Frequency {
self.frequency
}
pub fn index_is_interpolated(&self) -> bool {
self.index_is_interpolated
}
fn observed(&self, date: Date) -> QlResult<Date> {
if self.index_is_interpolated {
Ok(date)
} else {
Ok(inflation_period(date, self.frequency)?.0)
}
}
pub fn time_from_base(&self, date: Date, obs_lag: Period) -> QlResult<Time> {
let observed = self.observed(date - obs_lag)?;
Ok(self
.require_day_counter()?
.year_fraction(self.base_date()?, observed))
}
fn check_range(&self, date: Date, strike: Rate) -> QlResult<()> {
let base_date = self.base_date()?;
require!(
date >= base_date,
"date ({date}) is before base date ({base_date})"
);
require!(
self.allows_extrapolation() || (strike >= self.min_strike && strike <= self.max_strike),
"strike ({strike}) is outside the curve domain [{min},{max}] at date = {date}",
min = self.min_strike,
max = self.max_strike
);
Ok(())
}
}
impl AsObservable for ConstantYoYOptionletVolatility {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl TermStructure for ConstantYoYOptionletVolatility {
fn base(&self) -> &TermStructureBase {
&self.base
}
fn max_date(&self) -> Date {
Date::max_date()
}
}
impl VolatilityTermStructure for ConstantYoYOptionletVolatility {
fn business_day_convention(&self) -> BusinessDayConvention {
self.business_day_convention
}
fn min_strike(&self) -> Rate {
self.min_strike
}
fn max_strike(&self) -> Rate {
self.max_strike
}
}
impl YoYOptionletVolatilitySurface for ConstantYoYOptionletVolatility {
fn base_date(&self) -> QlResult<Date> {
self.observed(self.reference_date()? - self.observation_lag)
}
fn volatility(&self, date: Date, strike: Rate, obs_lag: Period) -> QlResult<Volatility> {
let observed = self.observed(date - obs_lag)?;
self.check_range(observed, strike)?;
self.volatility.current_link()?.value()
}
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.time_from_base(date, obs_lag)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quotes::SimpleQuote;
use crate::shared::shared;
use crate::test_support::{Flag, as_observer};
use crate::time::calendars::unitedkingdom::{self, UnitedKingdom};
use crate::time::date::Month::{April, July, June, March, May};
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::timeunit::TimeUnit;
const VOL: Volatility = 0.01;
fn lag() -> Period {
Period::new(2, TimeUnit::Months)
}
fn zero_lag() -> Period {
Period::new(0, TimeUnit::Days)
}
fn surface(index_is_interpolated: bool) -> ConstantYoYOptionletVolatility {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, June, 2026));
ConstantYoYOptionletVolatility::new(
VOL,
0,
UnitedKingdom::new(unitedkingdom::Market::Settlement),
BusinessDayConvention::ModifiedFollowing,
Actual365Fixed::new(),
lag(),
Frequency::Monthly,
index_is_interpolated,
-1.0,
100.0,
settings,
)
}
#[test]
fn the_base_date_snaps_to_the_publication_period_unless_interpolated() {
assert_eq!(
surface(false).base_date().unwrap(),
Date::new(1, April, 2026)
);
assert_eq!(
surface(true).base_date().unwrap(),
Date::new(15, April, 2026)
);
}
#[test]
fn the_total_variance_accrues_over_the_handed_lag() {
let surface = surface(false);
let exercise = Date::new(20, July, 2026);
let time = surface.time_from_base(exercise, zero_lag()).unwrap();
let expected = Actual365Fixed::new()
.year_fraction(Date::new(1, April, 2026), Date::new(1, July, 2026));
assert!((time - expected).abs() < 1e-15, "time was {time}");
let variance = surface.total_variance(exercise, 0.03, zero_lag()).unwrap();
assert!(
(variance - VOL * VOL * expected).abs() < 1e-18,
"variance was {variance}"
);
let lagged = surface.time_from_base(exercise, lag()).unwrap();
let lagged_expected =
Actual365Fixed::new().year_fraction(Date::new(1, April, 2026), Date::new(1, May, 2026));
assert!(
(lagged - lagged_expected).abs() < 1e-15,
"the surface's own lag gives {lagged}"
);
}
#[test]
fn the_volatility_is_flat_and_follows_its_quote() {
let surface = surface(false);
for date in [Date::new(1, July, 2026), Date::new(20, July, 2030)] {
for strike in [-0.5, 0.0, 0.03, 50.0] {
assert_eq!(surface.volatility(date, strike, zero_lag()).unwrap(), VOL);
}
}
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, June, 2026));
let quote = make_quote_handle(0.02);
let quoted = ConstantYoYOptionletVolatility::with_quote(
quote.handle(),
0,
UnitedKingdom::new(unitedkingdom::Market::Settlement),
BusinessDayConvention::ModifiedFollowing,
Actual365Fixed::new(),
lag(),
Frequency::Monthly,
false,
-1.0,
100.0,
settings,
);
let flag = Flag::new();
quoted.observable().register_observer(&as_observer(&flag));
quote.link_to(shared(SimpleQuote::new(0.05)) as Shared<dyn Quote>);
assert!(Flag::is_up(&flag));
assert_eq!(
quoted
.volatility(Date::new(20, July, 2026), 0.03, zero_lag())
.unwrap(),
0.05
);
}
#[test]
fn a_date_before_the_base_date_or_a_strike_off_the_domain_is_rejected() {
let surface = surface(false);
let early = surface
.volatility(Date::new(20, March, 2026), 0.03, zero_lag())
.expect_err("March 2026 precedes the April base date");
assert!(early.message().contains("before base date"), "err: {early}");
let wide = surface
.volatility(Date::new(20, July, 2026), 200.0, zero_lag())
.expect_err("200 is past the 100 maximum strike");
assert!(wide.message().contains("outside the curve"), "err: {wide}");
surface.enable_extrapolation();
assert_eq!(
surface
.volatility(Date::new(20, July, 2026), 200.0, zero_lag())
.unwrap(),
VOL
);
}
}