use std::cell::RefCell;
use std::cmp::Ordering;
use crate::errors::QlResult;
use crate::handle::Handle;
use crate::indexes::IborIndex;
use crate::indexes::interestrateindex::InterestRateIndex;
use crate::shared::Shared;
use crate::termstructures::TermStructure;
use crate::termstructures::volatility::{
CapFloorTermVolSurface, VolatilityTermStructure, VolatilityType,
};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::period::Period;
use crate::types::{Natural, Rate, Real, Time, Volatility};
#[derive(Default)]
pub struct OptionletStripperCaches {
pub optionlet_strikes: Vec<Vec<Rate>>,
pub optionlet_volatilities: Vec<Vec<Volatility>>,
pub optionlet_times: Vec<Time>,
pub optionlet_dates: Vec<Date>,
pub atm_optionlet_rate: Vec<Rate>,
pub optionlet_payment_dates: Vec<Date>,
pub optionlet_accrual_periods: Vec<Time>,
}
pub struct OptionletStripper {
term_vol_surface: Shared<CapFloorTermVolSurface>,
ibor_index: Shared<IborIndex>,
discount: Handle<dyn YieldTermStructure>,
n_strikes: usize,
optionlet_tenors: Vec<Period>,
cap_floor_lengths: Vec<Period>,
volatility_type: VolatilityType,
displacement: Real,
optionlet_frequency: Option<Period>,
caches: RefCell<OptionletStripperCaches>,
}
impl OptionletStripper {
pub fn new(
surface: Shared<CapFloorTermVolSurface>,
ibor_index: Shared<IborIndex>,
discount: Handle<dyn YieldTermStructure>,
volatility_type: VolatilityType,
displacement: Real,
optionlet_frequency: Option<Period>,
) -> QlResult<OptionletStripper> {
if volatility_type == VolatilityType::Normal && displacement != 0.0 {
crate::fail!("non-null displacement is not allowed with Normal model");
}
let n_strikes = surface.strikes().len();
let index_tenor = optionlet_frequency.unwrap_or_else(|| ibor_index.tenor());
let Some(&max_cap_floor_tenor) = surface.option_tenors().last() else {
crate::fail!("cap/floor term vol surface has no option tenors");
};
let mut optionlet_tenors = vec![index_tenor];
let mut cap_floor_lengths = vec![index_tenor + index_tenor];
match max_cap_floor_tenor.partial_cmp(&cap_floor_lengths[0]) {
Some(Ordering::Less) => crate::fail!(
"too short ({max_cap_floor_tenor}) capfloor term vol surface for optionlet tenor {index_tenor}"
),
None => crate::fail!(
"undecidable comparison between surface tenor {max_cap_floor_tenor} and capfloor length {}",
cap_floor_lengths[0]
),
_ => {}
}
let mut next = cap_floor_lengths[0] + index_tenor;
loop {
match next.partial_cmp(&max_cap_floor_tenor) {
Some(Ordering::Greater) => break,
None => crate::fail!(
"undecidable comparison between capfloor length {next} and surface tenor {max_cap_floor_tenor}"
),
_ => {}
}
optionlet_tenors.push(*cap_floor_lengths.last().expect("non-empty"));
cap_floor_lengths.push(next);
next += index_tenor;
}
let n_optionlet_tenors = optionlet_tenors.len();
let caches = OptionletStripperCaches {
optionlet_strikes: vec![surface.strikes().to_vec(); n_optionlet_tenors],
optionlet_volatilities: vec![vec![0.0; n_strikes]; n_optionlet_tenors],
optionlet_times: vec![0.0; n_optionlet_tenors],
optionlet_dates: vec![Date::null(); n_optionlet_tenors],
atm_optionlet_rate: vec![0.0; n_optionlet_tenors],
optionlet_payment_dates: vec![Date::null(); n_optionlet_tenors],
optionlet_accrual_periods: vec![0.0; n_optionlet_tenors],
};
Ok(OptionletStripper {
term_vol_surface: surface,
ibor_index,
discount,
n_strikes,
optionlet_tenors,
cap_floor_lengths,
volatility_type,
displacement,
optionlet_frequency,
caches: RefCell::new(caches),
})
}
pub fn optionlet_fixing_tenors(&self) -> &[Period] {
&self.optionlet_tenors
}
pub fn cap_floor_lengths(&self) -> &[Period] {
&self.cap_floor_lengths
}
pub fn optionlet_maturities(&self) -> usize {
self.optionlet_tenors.len()
}
pub fn n_strikes(&self) -> usize {
self.n_strikes
}
pub fn term_vol_surface(&self) -> &Shared<CapFloorTermVolSurface> {
&self.term_vol_surface
}
pub fn ibor_index(&self) -> &Shared<IborIndex> {
&self.ibor_index
}
pub fn discount(&self) -> &Handle<dyn YieldTermStructure> {
&self.discount
}
pub fn optionlet_frequency(&self) -> Option<Period> {
self.optionlet_frequency
}
pub fn volatility_type(&self) -> VolatilityType {
self.volatility_type
}
pub fn displacement(&self) -> Real {
self.displacement
}
pub fn day_counter(&self) -> Option<DayCounter> {
self.term_vol_surface.day_counter()
}
pub fn calendar(&self) -> Option<Calendar> {
self.term_vol_surface.calendar()
}
pub fn settlement_days(&self) -> QlResult<Natural> {
self.term_vol_surface.settlement_days()
}
pub fn business_day_convention(&self) -> BusinessDayConvention {
self.term_vol_surface.business_day_convention()
}
pub fn caches(&self) -> &RefCell<OptionletStripperCaches> {
&self.caches
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quotes::SimpleQuote;
use crate::settings::Settings;
use crate::shared::shared;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendars::target::Target;
use crate::time::date::Month;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::timeunit::TimeUnit;
use crate::types::Real;
const BDC: BusinessDayConvention = BusinessDayConvention::ModifiedFollowing;
fn settings_on(today: Date) -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
settings
}
fn euribor6m(settings: Shared<Settings<Date>>) -> Shared<IborIndex> {
shared(crate::indexes::ibor::Euribor::six_months(
Handle::<dyn YieldTermStructure>::empty(),
settings,
))
}
fn months(n: i64) -> Period {
Period::new(n as crate::types::Integer, TimeUnit::Months)
}
fn surface(option_tenors: Vec<Period>, n_strikes: usize) -> Shared<CapFloorTermVolSurface> {
let strikes: Vec<Rate> = (0..n_strikes).map(|j| 0.01 + 0.01 * j as Real).collect();
let vols: Vec<Vec<Handle<dyn crate::quotes::Quote>>> = option_tenors
.iter()
.map(|_| {
(0..n_strikes)
.map(|_| {
let quote: Shared<dyn crate::quotes::Quote> =
shared(SimpleQuote::new(Some(0.20)));
Handle::new(quote)
})
.collect()
})
.collect();
shared(
CapFloorTermVolSurface::with_reference_date(
Date::new(15, Month::June, 2026),
Target::new(),
BDC,
option_tenors,
strikes,
vols,
Actual365Fixed::new(),
)
.unwrap(),
)
}
#[test]
fn common_setup_builds_the_optionlet_and_capfloor_tenors() {
let settings = settings_on(Date::new(15, Month::June, 2026));
let index = euribor6m(settings.clone());
let option_tenors = (1..=4)
.map(|n| Period::new(n, TimeUnit::Years))
.collect::<Vec<_>>();
let surface = surface(option_tenors, 5);
let stripper = OptionletStripper::new(
surface,
index,
Handle::<dyn YieldTermStructure>::empty(),
VolatilityType::ShiftedLognormal,
0.0,
None,
)
.unwrap();
let expected_optionlets: Vec<Period> = [6, 12, 18, 24, 30, 36, 42]
.iter()
.map(|&m| months(m))
.collect();
let expected_lengths: Vec<Period> = [12, 18, 24, 30, 36, 42, 48]
.iter()
.map(|&m| months(m))
.collect();
assert_eq!(
stripper.optionlet_fixing_tenors(),
expected_optionlets.as_slice()
);
assert_eq!(stripper.cap_floor_lengths(), expected_lengths.as_slice());
assert_eq!(stripper.optionlet_maturities(), 7);
assert_eq!(stripper.n_strikes(), 5);
}
#[test]
fn common_setup_allocates_the_result_caches() {
let settings = settings_on(Date::new(15, Month::June, 2026));
let index = euribor6m(settings.clone());
let surface = surface(
(1..=4).map(|n| Period::new(n, TimeUnit::Years)).collect(),
5,
);
let stripper = OptionletStripper::new(
surface,
index,
Handle::<dyn YieldTermStructure>::empty(),
VolatilityType::ShiftedLognormal,
0.0,
None,
)
.unwrap();
let caches = stripper.caches().borrow();
assert_eq!(caches.optionlet_strikes.len(), 7);
assert_eq!(
caches.optionlet_strikes[0],
vec![0.01, 0.02, 0.03, 0.04, 0.05]
);
assert_eq!(caches.optionlet_volatilities.len(), 7);
assert!(
caches
.optionlet_volatilities
.iter()
.all(|row| row.len() == 5)
);
assert_eq!(caches.optionlet_dates.len(), 7);
assert_eq!(caches.atm_optionlet_rate.len(), 7);
}
#[test]
fn a_too_short_surface_is_rejected() {
let settings = settings_on(Date::new(15, Month::June, 2026));
let index = euribor6m(settings.clone());
let surface = surface(vec![months(3), months(6), months(9), months(11)], 5);
let err = OptionletStripper::new(
surface,
index,
Handle::<dyn YieldTermStructure>::empty(),
VolatilityType::ShiftedLognormal,
0.0,
None,
)
.err()
.unwrap();
assert!(err.message().contains("too short"));
}
#[test]
fn normal_model_forbids_a_displacement() {
let settings = settings_on(Date::new(15, Month::June, 2026));
let index = euribor6m(settings.clone());
let surface = surface(
(1..=4).map(|n| Period::new(n, TimeUnit::Years)).collect(),
5,
);
let err = OptionletStripper::new(
surface,
index,
Handle::<dyn YieldTermStructure>::empty(),
VolatilityType::Normal,
0.01,
None,
)
.err()
.unwrap();
assert!(err.message().contains("Normal model"));
}
}