use crate::errors::QlResult;
use crate::handle::Handle;
use crate::indexes::{Index, InterestRateIndex, SwapIndex};
use crate::patterns::observable::Observable;
use crate::quotes::Quote;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::termstructures::TermStructureBase;
use crate::termstructures::volatility::{SmileSection, VolatilityType};
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::date::Date;
use crate::time::period::Period;
use crate::types::{Rate, Real, Time, Volatility};
use crate::{fail, require};
use super::{SwaptionVolatilityDiscrete, SwaptionVolatilityStructure};
const REQUIRED_NUMBER_OF_STRIKES: usize = 2;
pub trait SwaptionCubeSmileSection {
fn smile_section_impl(
&self,
option_time: Time,
swap_length: Time,
) -> QlResult<Shared<dyn SmileSection>>;
fn cube_volatility_impl(
&self,
option_time: Time,
swap_length: Time,
strike: Rate,
) -> QlResult<Volatility> {
self.smile_section_impl(option_time, swap_length)?
.volatility(strike)
}
}
pub struct SwaptionVolatilityCube {
discrete: SwaptionVolatilityDiscrete,
atm_vol: Handle<dyn SwaptionVolatilityStructure>,
strike_spreads: Vec<Real>,
vol_spreads: Vec<Vec<Handle<dyn Quote>>>,
swap_index_base: Shared<SwapIndex>,
short_swap_index_base: Shared<SwapIndex>,
vega_weighted_smile_fit: bool,
}
impl SwaptionVolatilityCube {
#[allow(clippy::too_many_arguments)]
pub fn new(
atm_vol: Handle<dyn SwaptionVolatilityStructure>,
option_tenors: Vec<Period>,
swap_tenors: Vec<Period>,
strike_spreads: Vec<Real>,
vol_spreads: Vec<Vec<Handle<dyn Quote>>>,
swap_index_base: Shared<SwapIndex>,
short_swap_index_base: Shared<SwapIndex>,
vega_weighted_smile_fit: bool,
settings: Shared<Settings<Date>>,
) -> QlResult<SwaptionVolatilityCube> {
let atm = atm_vol.current_link()?;
let Some(calendar) = atm.calendar() else {
fail!("atm vol structure has no calendar");
};
let business_day_convention = atm.business_day_convention();
let Some(day_counter) = atm.day_counter() else {
fail!("atm vol structure has no day counter");
};
let discrete = SwaptionVolatilityDiscrete::moving(
option_tenors,
swap_tenors,
0,
calendar,
business_day_convention,
day_counter,
settings,
)?;
let n_strikes = strike_spreads.len();
require!(
n_strikes >= REQUIRED_NUMBER_OF_STRIKES,
"too few strikes ({n_strikes}): at least {REQUIRED_NUMBER_OF_STRIKES} are required"
);
for i in 1..n_strikes {
let increasing = strike_spreads[i - 1] < strike_spreads[i];
require!(
increasing,
"non increasing strike spreads: {} is {}, {} is {}",
i,
strike_spreads[i - 1],
i + 1,
strike_spreads[i]
);
}
require!(!vol_spreads.is_empty(), "empty vol spreads matrix");
let n_options = discrete.option_tenors().len();
let n_swaps = discrete.swap_tenors().len();
require!(
n_options * n_swaps == vol_spreads.len(),
"mismatch between number of option tenors * swap tenors ({}) and number of rows ({})",
n_options * n_swaps,
vol_spreads.len()
);
for (i, row) in vol_spreads.iter().enumerate() {
require!(
row.len() == n_strikes,
"mismatch between number of strikes ({n_strikes}) and number of columns ({}) in the {} row",
row.len(),
i + 1
);
}
let updater = discrete.base().updater();
atm_vol.register_observer(&updater);
atm.enable_extrapolation();
swap_index_base
.base()
.observable()
.register_observer(&updater);
short_swap_index_base
.base()
.observable()
.register_observer(&updater);
let short_not_longer = short_swap_index_base.tenor() <= swap_index_base.tenor();
require!(
short_not_longer,
"short index tenor ({}) is not less or equal than index tenor ({})",
short_swap_index_base.tenor(),
swap_index_base.tenor()
);
for row in &vol_spreads {
for handle in row {
handle.register_observer(&updater);
}
}
Ok(SwaptionVolatilityCube {
discrete,
atm_vol,
strike_spreads,
vol_spreads,
swap_index_base,
short_swap_index_base,
vega_weighted_smile_fit,
})
}
pub fn discrete(&self) -> &SwaptionVolatilityDiscrete {
&self.discrete
}
pub fn base(&self) -> &TermStructureBase {
self.discrete.base()
}
pub fn observable(&self) -> &Observable {
self.discrete.observable()
}
pub fn calculate(&self) -> QlResult<()> {
self.discrete.calculate()
}
pub fn business_day_convention(&self) -> BusinessDayConvention {
self.discrete.business_day_convention()
}
pub fn atm_vol(&self) -> Handle<dyn SwaptionVolatilityStructure> {
self.atm_vol.clone()
}
pub fn strike_spreads(&self) -> &[Real] {
&self.strike_spreads
}
pub fn vol_spreads(&self) -> &[Vec<Handle<dyn Quote>>] {
&self.vol_spreads
}
pub fn swap_index_base(&self) -> Shared<SwapIndex> {
Shared::clone(&self.swap_index_base)
}
pub fn short_swap_index_base(&self) -> Shared<SwapIndex> {
Shared::clone(&self.short_swap_index_base)
}
pub fn vega_weighted_smile_fit(&self) -> bool {
self.vega_weighted_smile_fit
}
pub fn volatility_type(&self) -> QlResult<VolatilityType> {
Ok(self.atm_vol.current_link()?.volatility_type())
}
pub fn atm_strike(&self, option_date: Date, swap_tenor: Period) -> QlResult<Rate> {
let chosen = if swap_tenor > self.short_swap_index_base.tenor() {
&self.swap_index_base
} else {
&self.short_swap_index_base
};
let settings = chosen.base().settings().clone();
let index = if chosen.exogenous_discount() {
SwapIndex::with_exogenous_discount(
chosen.family_name().to_string(),
swap_tenor,
chosen.fixing_days(),
chosen.currency().clone(),
chosen.fixing_calendar(),
chosen.fixed_leg_tenor(),
chosen.fixed_leg_convention(),
chosen.day_counter().clone(),
chosen.ibor_index(),
chosen.discounting_term_structure(),
settings,
)
} else {
SwapIndex::new(
chosen.family_name().to_string(),
swap_tenor,
chosen.fixing_days(),
chosen.currency().clone(),
chosen.fixing_calendar(),
chosen.fixed_leg_tenor(),
chosen.fixed_leg_convention(),
chosen.day_counter().clone(),
chosen.ibor_index(),
settings,
)
};
index.fixing(option_date, false)
}
pub fn atm_strike_from_tenor(
&self,
option_tenor: Period,
swap_tenor: Period,
) -> QlResult<Rate> {
let Some(calendar) = self.discrete.base().calendar() else {
fail!("no calendar for swaption vol cube");
};
let reference = self.discrete.base().reference_date()?;
let option_date = calendar.advance_by_period(
reference,
option_tenor,
self.business_day_convention(),
false,
);
self.atm_strike(option_date, swap_tenor)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::currency::Currency;
use crate::indexes::{Euribor, IborIndex};
use crate::interestrate::Compounding;
use crate::patterns::observable::AsObservable;
use crate::quotes::make_quote_handle;
use crate::shared::shared;
use crate::termstructures::volatility::FlatSmileSection;
use crate::termstructures::yields::FlatForward;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::termstructures::{TermStructure, volatility::VolatilityTermStructure};
use crate::time::calendars::target::Target;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::time::daycounters::thirty360::{Convention, Thirty360};
use crate::time::frequency::Frequency;
use crate::time::timeunit::TimeUnit;
const BDC: BusinessDayConvention = BusinessDayConvention::ModifiedFollowing;
fn today() -> Date {
Date::new(15, Month::June, 2026)
}
fn option_date() -> Date {
Date::new(15, Month::June, 2027)
}
struct MockAtmVol {
base: TermStructureBase,
vol: Volatility,
}
impl AsObservable for MockAtmVol {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl TermStructure for MockAtmVol {
fn base(&self) -> &TermStructureBase {
&self.base
}
fn max_date(&self) -> Date {
Date::max_date()
}
}
impl VolatilityTermStructure for MockAtmVol {
fn business_day_convention(&self) -> BusinessDayConvention {
BDC
}
fn min_strike(&self) -> Rate {
Rate::MIN
}
fn max_strike(&self) -> Rate {
Rate::MAX
}
}
impl SwaptionVolatilityStructure for MockAtmVol {
fn volatility_impl(&self, _t: Time, _l: Time, _strike: Rate) -> QlResult<Volatility> {
Ok(self.vol)
}
fn max_swap_tenor(&self) -> Period {
Period::new(100, TimeUnit::Years)
}
}
fn settings_today() -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today());
settings
}
fn flat_curve(rate: Rate) -> Handle<dyn YieldTermStructure> {
Handle::new(shared(FlatForward::with_rate(
today(),
rate,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
}
fn atm_handle(vol: Volatility) -> Handle<dyn SwaptionVolatilityStructure> {
Handle::new(shared(MockAtmVol {
base: TermStructureBase::with_reference_date(
today(),
Some(Target::new()),
Some(Actual365Fixed::new()),
),
vol,
}) as Shared<dyn SwaptionVolatilityStructure>)
}
fn long_index(
euribor6m: &Shared<IborIndex>,
discount: &Handle<dyn YieldTermStructure>,
settings: &Shared<Settings<Date>>,
) -> SwapIndex {
SwapIndex::with_exogenous_discount(
"LongSwap".into(),
Period::new(5, TimeUnit::Years),
2,
Currency::eur(),
Target::new(),
Period::new(1, TimeUnit::Years),
BDC,
Thirty360::with_convention(Convention::BondBasis),
Shared::clone(euribor6m),
discount.clone(),
Shared::clone(settings),
)
}
fn short_index(euribor6m: &Shared<IborIndex>, settings: &Shared<Settings<Date>>) -> SwapIndex {
SwapIndex::new(
"ShortSwap".into(),
Period::new(1, TimeUnit::Years),
2,
Currency::eur(),
Target::new(),
Period::new(6, TimeUnit::Months),
BDC,
Thirty360::with_convention(Convention::BondBasis),
Shared::clone(euribor6m),
Shared::clone(settings),
)
}
struct Parts {
settings: Shared<Settings<Date>>,
euribor6m: Shared<IborIndex>,
discount: Handle<dyn YieldTermStructure>,
long: Shared<SwapIndex>,
short: Shared<SwapIndex>,
}
fn parts() -> Parts {
let settings = settings_today();
let euribor6m = shared(Euribor::six_months(
flat_curve(0.05),
Shared::clone(&settings),
));
let discount = flat_curve(0.03);
let long = shared(long_index(&euribor6m, &discount, &settings));
let short = shared(short_index(&euribor6m, &settings));
Parts {
settings,
euribor6m,
discount,
long,
short,
}
}
fn option_tenors() -> Vec<Period> {
vec![
Period::new(1, TimeUnit::Years),
Period::new(2, TimeUnit::Years),
]
}
fn swap_tenors() -> Vec<Period> {
vec![
Period::new(1, TimeUnit::Years),
Period::new(5, TimeUnit::Years),
]
}
fn vol_spreads(n_rows: usize, n_strikes: usize) -> Vec<Vec<Handle<dyn Quote>>> {
(0..n_rows)
.map(|_| {
(0..n_strikes)
.map(|_| make_quote_handle(0.001).handle())
.collect()
})
.collect()
}
fn build_cube(
p: &Parts,
strike_spreads: Vec<Real>,
vol_spreads: Vec<Vec<Handle<dyn Quote>>>,
) -> QlResult<SwaptionVolatilityCube> {
SwaptionVolatilityCube::new(
atm_handle(0.2),
option_tenors(),
swap_tenors(),
strike_spreads,
vol_spreads,
Shared::clone(&p.long),
Shared::clone(&p.short),
false,
Shared::clone(&p.settings),
)
}
fn valid_cube(p: &Parts) -> SwaptionVolatilityCube {
build_cube(p, vec![-0.01, 0.0, 0.01], vol_spreads(4, 3)).unwrap()
}
#[test]
fn atm_strike_long_branch_uses_the_long_base_conventions() {
let p = parts();
let cube = valid_cube(&p);
let swap_tenor = Period::new(2, TimeUnit::Years);
let mut expected_index = long_index(&p.euribor6m, &p.discount, &p.settings);
expected_index = SwapIndex::with_exogenous_discount(
"LongSwap".into(),
swap_tenor,
2,
Currency::eur(),
Target::new(),
Period::new(1, TimeUnit::Years),
BDC,
Thirty360::with_convention(Convention::BondBasis),
expected_index.ibor_index(),
p.discount.clone(),
Shared::clone(&p.settings),
);
let expected = expected_index.fixing(option_date(), false).unwrap();
let got = cube.atm_strike(option_date(), swap_tenor).unwrap();
assert!(
(got - expected).abs() < 1e-14,
"long-branch atm strike {got} vs independently built {expected}"
);
assert!(got > 0.0, "a positive swap rate off a 5% forwarding curve");
}
#[test]
fn atm_strike_short_branch_uses_the_short_base_conventions() {
let p = parts();
let cube = valid_cube(&p);
let swap_tenor = Period::new(1, TimeUnit::Years);
let expected_index = SwapIndex::new(
"ShortSwap".into(),
swap_tenor,
2,
Currency::eur(),
Target::new(),
Period::new(6, TimeUnit::Months),
BDC,
Thirty360::with_convention(Convention::BondBasis),
Shared::clone(&p.euribor6m),
Shared::clone(&p.settings),
);
let expected = expected_index.fixing(option_date(), false).unwrap();
let got = cube.atm_strike(option_date(), swap_tenor).unwrap();
assert!(
(got - expected).abs() < 1e-14,
"short-branch atm strike {got} vs independently built {expected}"
);
}
#[test]
fn atm_strike_branches_differ_by_base() {
let p = parts();
let cube = valid_cube(&p);
let long_branch = cube
.atm_strike(option_date(), Period::new(2, TimeUnit::Years))
.unwrap();
let short_branch = cube
.atm_strike(option_date(), Period::new(1, TimeUnit::Years))
.unwrap();
assert!(
(long_branch - short_branch).abs() > 1e-6,
"the two bases must produce distinct rates, got {long_branch} and {short_branch}"
);
}
#[test]
fn too_few_strikes_is_rejected() {
let p = parts();
assert!(build_cube(&p, vec![0.0], vol_spreads(4, 1)).is_err());
}
#[test]
fn non_increasing_strike_spreads_are_rejected() {
let p = parts();
assert!(build_cube(&p, vec![0.01, 0.0, -0.01], vol_spreads(4, 3)).is_err());
}
#[test]
fn wrong_shaped_vol_spreads_are_rejected() {
let p = parts();
assert!(
build_cube(&p, vec![-0.01, 0.0, 0.01], vol_spreads(3, 3)).is_err(),
"row count must equal option tenors * swap tenors"
);
assert!(
build_cube(&p, vec![-0.01, 0.0, 0.01], vol_spreads(4, 2)).is_err(),
"each row must hold one quote per strike"
);
}
#[test]
fn short_tenor_longer_than_long_tenor_is_rejected() {
let p = parts();
let swapped = SwaptionVolatilityCube::new(
atm_handle(0.2),
option_tenors(),
swap_tenors(),
vec![-0.01, 0.0, 0.01],
vol_spreads(4, 3),
Shared::clone(&p.short),
Shared::clone(&p.long),
false,
Shared::clone(&p.settings),
);
assert!(
swapped.is_err(),
"short (5Y) longer than long (1Y) must be rejected"
);
}
struct StubCube {
cube: SwaptionVolatilityCube,
flat_vol: Volatility,
}
impl SwaptionCubeSmileSection for StubCube {
fn smile_section_impl(
&self,
option_time: Time,
swap_length: Time,
) -> QlResult<Shared<dyn SmileSection>> {
let _ = swap_length;
let section = FlatSmileSection::with_exercise_time(
option_time,
self.flat_vol,
Actual365Fixed::new(),
Some(0.03),
VolatilityType::ShiftedLognormal,
0.0,
)?;
Ok(shared(section) as Shared<dyn SmileSection>)
}
}
impl AsObservable for StubCube {
fn observable(&self) -> &Observable {
self.cube.observable()
}
}
impl TermStructure for StubCube {
fn base(&self) -> &TermStructureBase {
self.cube.base()
}
fn max_date(&self) -> Date {
self.cube
.atm_vol()
.current_link()
.map(|a| a.max_date())
.unwrap_or_else(|_| Date::max_date())
}
}
impl VolatilityTermStructure for StubCube {
fn business_day_convention(&self) -> BusinessDayConvention {
self.cube.business_day_convention()
}
fn min_strike(&self) -> Rate {
Rate::MIN
}
fn max_strike(&self) -> Rate {
Rate::MAX
}
}
impl SwaptionVolatilityStructure for StubCube {
fn volatility_impl(
&self,
option_time: Time,
swap_length: Time,
strike: Rate,
) -> QlResult<Volatility> {
self.cube_volatility_impl(option_time, swap_length, strike)
}
fn max_swap_tenor(&self) -> Period {
Period::new(100, TimeUnit::Years)
}
fn volatility_type(&self) -> VolatilityType {
self.cube
.volatility_type()
.unwrap_or(VolatilityType::ShiftedLognormal)
}
}
#[test]
fn volatility_impl_routes_through_the_smile_hook() {
let p = parts();
let stub = StubCube {
cube: valid_cube(&p),
flat_vol: 0.17,
};
for strike in [0.01, 0.03, 0.08] {
let got = stub.volatility_impl(1.0, 5.0, strike).unwrap();
assert!(
(got - 0.17).abs() < 1e-15,
"routing must return the flat smile vol at strike {strike}, got {got}"
);
}
}
}