use std::any::Any;
use std::cell::RefCell;
use crate::errors::QlResult;
use crate::event::event_has_occurred;
use crate::exercise::{Exercise, ExerciseType};
use crate::handle::Handle;
use crate::instrument::{Instrument, InstrumentBase, InstrumentResults};
use crate::instruments::fixedvsfloatingswap::{FixedVsFloatingSwap, FixedVsFloatingSwapArguments};
use crate::instruments::swap::SwapType;
use crate::math::solver1d::{DerivativeSolver, Function1D};
use crate::math::solvers1d::newtonsafe::NewtonSafe;
use crate::pricingengine::{Arguments, GenericEngine, PricingEngine};
use crate::pricingengines::swaption::{
BachelierSwaptionEngine, BlackSwaptionEngine, CashAnnuityModel,
};
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::{Shared, SharedMut, shared};
use crate::termstructures::volatility::VolatilityType;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::date::Date;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
use crate::types::{Real, Size, Volatility};
use crate::{fail, require};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SwaptionPriceType {
#[default]
Spot,
Forward,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SettlementType {
#[default]
Physical,
Cash,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SettlementMethod {
#[default]
PhysicalOTC,
PhysicalCleared,
CollateralizedCashPrice,
ParYieldCurve,
}
pub fn check_type_and_method_consistency(
settlement_type: SettlementType,
settlement_method: SettlementMethod,
) -> QlResult<()> {
match settlement_type {
SettlementType::Physical => require!(
matches!(
settlement_method,
SettlementMethod::PhysicalOTC | SettlementMethod::PhysicalCleared
),
"invalid settlement method for physical settlement"
),
SettlementType::Cash => require!(
matches!(
settlement_method,
SettlementMethod::CollateralizedCashPrice | SettlementMethod::ParYieldCurve
),
"invalid settlement method for cash settlement"
),
}
Ok(())
}
#[derive(Default)]
pub struct SwaptionArguments {
pub swap_arguments: FixedVsFloatingSwapArguments,
pub swap: Option<SharedMut<FixedVsFloatingSwap>>,
pub settlement_type: SettlementType,
pub settlement_method: SettlementMethod,
pub exercise: Option<Shared<dyn Exercise>>,
}
impl Arguments for SwaptionArguments {
fn validate(&self) -> QlResult<()> {
self.swap_arguments.validate()?;
require!(self.swap.is_some(), "swap not set");
require!(self.exercise.is_some(), "exercise not set");
check_type_and_method_consistency(self.settlement_type, self.settlement_method)
}
}
pub type SwaptionEngine = GenericEngine<SwaptionArguments, InstrumentResults>;
pub struct Swaption {
base: InstrumentBase,
swap: SharedMut<FixedVsFloatingSwap>,
settlement_type: SettlementType,
settlement_method: SettlementMethod,
exercise: Shared<dyn Exercise>,
settings: Shared<Settings<Date>>,
}
impl Swaption {
pub fn new(
swap: SharedMut<FixedVsFloatingSwap>,
exercise: Shared<dyn Exercise>,
settlement_type: SettlementType,
settlement_method: SettlementMethod,
settings: Shared<Settings<Date>>,
) -> Swaption {
let base = InstrumentBase::new();
swap.borrow().base().register_observer(&base.observer());
settings.register_eval_date_observer(&base.observer());
Swaption {
base,
swap,
settlement_type,
settlement_method,
exercise,
settings,
}
}
pub fn settlement_type(&self) -> SettlementType {
self.settlement_type
}
pub fn settlement_method(&self) -> SettlementMethod {
self.settlement_method
}
pub fn swap_type(&self) -> SwapType {
self.swap.borrow().swap_type()
}
pub fn underlying(&self) -> &SharedMut<FixedVsFloatingSwap> {
&self.swap
}
pub fn exercise(&self) -> &Shared<dyn Exercise> {
&self.exercise
}
#[allow(clippy::too_many_arguments)]
pub fn implied_volatility(
&self,
mut target_value: Real,
discount_curve: Handle<dyn YieldTermStructure>,
guess: Volatility,
accuracy: Real,
max_evaluations: Size,
min_vol: Volatility,
max_vol: Volatility,
vol_type: VolatilityType,
displacement: Real,
price_type: SwaptionPriceType,
) -> QlResult<Volatility> {
require!(
target_value.is_finite() && target_value >= 0.0,
"invalid target value"
);
require!(displacement.is_finite(), "invalid displacement");
require!(
min_vol.is_finite() && min_vol >= 0.0,
"invalid minimum volatility"
);
require!(
max_vol.is_finite() && max_vol > min_vol,
"invalid maximum volatility"
);
require!(max_evaluations > 0, "maximum evaluations must be positive");
require!(!self.is_expired()?, "instrument expired");
require!(
self.exercise.exercise_type() == ExerciseType::European,
"not a European option"
);
if price_type == SwaptionPriceType::Forward {
let exercise_date = self.exercise.dates()[0];
target_value *= discount_curve
.current_link()?
.discount_date(exercise_date, false)?;
}
let vol = shared(SimpleQuote::new(-1.0));
let vol_handle = Handle::new(Shared::clone(&vol) as Shared<dyn Quote>);
let model = CashAnnuityModel::DiscountCurve;
let mut engine: Box<dyn PricingEngine> = match vol_type {
VolatilityType::ShiftedLognormal => Box::new(BlackSwaptionEngine::with_flat_vol(
discount_curve,
vol_handle,
Actual365Fixed::new(),
displacement,
model,
Shared::clone(&self.settings),
)),
VolatilityType::Normal => Box::new(BachelierSwaptionEngine::with_flat_vol(
discount_curve,
vol_handle,
Actual365Fixed::new(),
0.0,
model,
Shared::clone(&self.settings),
)),
};
self.setup_arguments(engine.arguments_mut())?;
engine.arguments_mut().validate()?;
let failure = RefCell::new(None);
let helper = ImpliedSwaptionVolHelper {
engine,
vol,
target_value,
failure: &failure,
};
let solver = NewtonSafe::new().with_max_evaluations(max_evaluations);
let root = solver.solve_bracketed(helper, accuracy, guess, min_vol, max_vol);
match failure.into_inner() {
Some(error) => Err(error),
None => root,
}
}
}
struct ImpliedSwaptionVolHelper<'a> {
engine: Box<dyn PricingEngine>,
vol: Shared<SimpleQuote>,
target_value: Real,
failure: &'a RefCell<Option<crate::errors::QlError>>,
}
impl ImpliedSwaptionVolHelper<'_> {
fn ensure_priced(&mut self, x: Volatility) {
if self.failure.borrow().is_some() {
return;
}
let current = self.vol.value().ok();
if current != Some(x) {
self.vol.set_value(x);
if let Err(error) = self.engine.calculate() {
self.failure.borrow_mut().get_or_insert(error);
}
}
}
fn npv(&self) -> Option<Real> {
self.engine
.results()
.as_instrument_results()
.and_then(|r| r.value)
}
fn vega(&self) -> Option<Real> {
self.engine
.results()
.as_instrument_results()
.and_then(|r| r.additional_results.get("vega"))
.and_then(|v| v.as_ref().downcast_ref::<Real>().copied())
}
}
impl Function1D for ImpliedSwaptionVolHelper<'_> {
fn value(&mut self, x: Real) -> Real {
self.ensure_priced(x);
if self.failure.borrow().is_some() {
return Real::NAN;
}
match self.npv() {
Some(value) => value - self.target_value,
None => {
self.failure.borrow_mut().get_or_insert_with(|| {
crate::errors::QlError::new(
"no results returned from pricing engine",
file!(),
line!(),
)
});
Real::NAN
}
}
}
fn derivative(&mut self, x: Real) -> Real {
self.ensure_priced(x);
if self.failure.borrow().is_some() {
return Real::NAN;
}
match self.vega() {
Some(vega) => vega,
None => {
self.failure.borrow_mut().get_or_insert_with(|| {
crate::errors::QlError::new("vega not provided", file!(), line!())
});
Real::NAN
}
}
}
}
impl Instrument for Swaption {
fn base(&self) -> &InstrumentBase {
&self.base
}
fn base_mut(&mut self) -> &mut InstrumentBase {
&mut self.base
}
fn is_expired(&self) -> QlResult<bool> {
event_has_occurred(self.exercise.last_date(), &self.settings, None, None)
}
fn setup_arguments(&self, arguments: &mut dyn Arguments) -> QlResult<()> {
let Some(args) = (arguments as &mut dyn Any).downcast_mut::<SwaptionArguments>() else {
fail!("wrong argument type");
};
self.swap
.borrow()
.setup_arguments(&mut args.swap_arguments)?;
args.swap = Some(SharedMut::clone(&self.swap));
args.settlement_type = self.settlement_type;
args.settlement_method = self.settlement_method;
args.exercise = Some(Shared::clone(&self.exercise));
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn consistency_accepts_matching_pairs_and_rejects_the_rest() {
use SettlementMethod::{
CollateralizedCashPrice, ParYieldCurve, PhysicalCleared, PhysicalOTC,
};
use SettlementType::{Cash, Physical};
assert!(check_type_and_method_consistency(Physical, PhysicalOTC).is_ok());
assert!(check_type_and_method_consistency(Physical, PhysicalCleared).is_ok());
assert!(check_type_and_method_consistency(Cash, CollateralizedCashPrice).is_ok());
assert!(check_type_and_method_consistency(Cash, ParYieldCurve).is_ok());
assert_eq!(
check_type_and_method_consistency(Physical, CollateralizedCashPrice)
.unwrap_err()
.message(),
"invalid settlement method for physical settlement"
);
assert!(check_type_and_method_consistency(Physical, ParYieldCurve).is_err());
assert_eq!(
check_type_and_method_consistency(Cash, PhysicalOTC)
.unwrap_err()
.message(),
"invalid settlement method for cash settlement"
);
assert!(check_type_and_method_consistency(Cash, PhysicalCleared).is_err());
}
use crate::cashflow::CashFlow;
use crate::cashflows::SimpleCashFlow;
use crate::exercise::EuropeanExercise;
use crate::handle::Handle;
use crate::indexes::IborIndex;
use crate::indexes::ibor::Euribor;
use crate::instruments::fixedvsfloatingswap::FloatingArgumentsFn;
use crate::shared::{shared, shared_mut};
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::actual360::Actual360;
use crate::time::frequency::Frequency;
use crate::time::schedule::MakeSchedule;
fn settings_on(today: Date) -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
settings
}
fn fixed_schedule() -> crate::time::schedule::Schedule {
MakeSchedule::new()
.from(Date::new(7, Month::July, 2027))
.to(Date::new(7, Month::July, 2029))
.with_frequency(Frequency::Annual)
.with_calendar(Target::new())
.with_convention(BusinessDayConvention::Following)
.build()
}
fn euribor(settings: &Shared<Settings<Date>>) -> Shared<IborIndex> {
shared(Euribor::three_months(
Handle::<dyn YieldTermStructure>::empty(),
Shared::clone(settings),
))
}
fn floating_stub_leg() -> crate::cashflow::Leg {
vec![
shared(SimpleCashFlow::new(1.0, Date::new(7, Month::July, 2028)).unwrap())
as Shared<dyn CashFlow>,
]
}
fn shared_swap(settings: &Shared<Settings<Date>>) -> SharedMut<FixedVsFloatingSwap> {
let noop: FloatingArgumentsFn = Box::new(|_, _| Ok(()));
shared_mut(
FixedVsFloatingSwap::new(
SwapType::Payer,
vec![100.0],
fixed_schedule(),
0.05,
Some(Actual360::new()),
vec![100.0],
fixed_schedule(),
euribor(settings),
0.001,
Actual360::new(),
None,
0,
None,
floating_stub_leg(),
noop,
Shared::clone(settings),
)
.unwrap(),
)
}
fn european(date: Date) -> Shared<dyn Exercise> {
shared(EuropeanExercise::new(date)) as Shared<dyn Exercise>
}
#[test]
fn validate_walks_the_swap_exercise_and_consistency_checks() {
let settings = settings_on(Date::new(7, Month::July, 2026));
let mut args = SwaptionArguments::default();
assert_eq!(args.validate().unwrap_err().message(), "swap not set");
args.swap = Some(shared_swap(&settings));
assert_eq!(args.validate().unwrap_err().message(), "exercise not set");
args.exercise = Some(european(Date::new(7, Month::July, 2028)));
args.settlement_type = SettlementType::Physical;
args.settlement_method = SettlementMethod::CollateralizedCashPrice;
assert_eq!(
args.validate().unwrap_err().message(),
"invalid settlement method for physical settlement"
);
args.settlement_method = SettlementMethod::PhysicalOTC;
assert!(args.validate().is_ok(), "a consistent pair validates");
}
#[test]
fn is_expired_tracks_the_last_exercise_date() {
let settings = settings_on(Date::new(7, Month::July, 2026));
let swaption = Swaption::new(
shared_swap(&settings),
european(Date::new(7, Month::July, 2028)),
SettlementType::Physical,
SettlementMethod::PhysicalOTC,
Shared::clone(&settings),
);
assert!(!swaption.is_expired().unwrap());
settings.set_evaluation_date(Date::new(8, Month::July, 2028));
assert!(swaption.is_expired().unwrap());
}
#[test]
fn setup_arguments_fills_the_swap_half_and_the_swaption_fields() {
let settings = settings_on(Date::new(7, Month::July, 2026));
let swap = shared_swap(&settings);
let swaption = Swaption::new(
SharedMut::clone(&swap),
european(Date::new(7, Month::July, 2028)),
SettlementType::Cash,
SettlementMethod::ParYieldCurve,
Shared::clone(&settings),
);
let mut args = SwaptionArguments::default();
swaption.setup_arguments(&mut args).unwrap();
assert_eq!(
args.swap_arguments.swap_type,
Some(SwapType::Payer),
"the swap's own setupArguments ran"
);
assert_eq!(
args.swap_arguments.fixed_pay_dates.len(),
2,
"two annual fixed coupons filled by the swap half"
);
assert_eq!(args.settlement_type, SettlementType::Cash);
assert_eq!(args.settlement_method, SettlementMethod::ParYieldCurve);
assert_eq!(
args.exercise.as_ref().unwrap().last_date(),
Date::new(7, Month::July, 2028)
);
assert!(
SharedMut::ptr_eq(args.swap.as_ref().unwrap(), &swap),
"the carried swap is the wrapped swap, not a copy"
);
}
#[test]
fn underlying_shares_identity_and_forwards_type() {
let settings = settings_on(Date::new(7, Month::July, 2026));
let swap = shared_swap(&settings);
let swaption = Swaption::new(
SharedMut::clone(&swap),
european(Date::new(7, Month::July, 2028)),
SettlementType::Physical,
SettlementMethod::PhysicalOTC,
Shared::clone(&settings),
);
assert!(SharedMut::ptr_eq(swaption.underlying(), &swap));
assert_eq!(swaption.swap_type(), SwapType::Payer);
assert_eq!(swaption.settlement_type(), SettlementType::Physical);
assert_eq!(swaption.settlement_method(), SettlementMethod::PhysicalOTC);
}
#[test]
fn setup_arguments_rejects_a_foreign_bundle() {
let settings = settings_on(Date::new(7, Month::July, 2026));
let swaption = Swaption::new(
shared_swap(&settings),
european(Date::new(7, Month::July, 2028)),
SettlementType::Physical,
SettlementMethod::PhysicalOTC,
settings,
);
let mut foreign = FixedVsFloatingSwapArguments::default();
assert_eq!(
swaption
.setup_arguments(&mut foreign)
.unwrap_err()
.message(),
"wrong argument type"
);
}
fn oracle_swaption(cash: bool) -> (Swaption, Handle<dyn YieldTermStructure>) {
use crate::instruments::MakeVanillaSwap;
use crate::interestrate::Compounding;
use crate::termstructures::yields::FlatForward;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
let today = Date::new(7, Month::July, 2026);
let settings = settings_on(today);
let curve = Handle::new(shared(FlatForward::with_rate(
today,
0.02,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>);
let index = shared(Euribor::six_months(curve.clone(), Shared::clone(&settings)));
let swap = MakeVanillaSwap::new(
Period::new(5, TimeUnit::Years),
index,
Some(0.03),
Period::new(0, TimeUnit::Days),
Shared::clone(&settings),
)
.with_effective_date(Date::new(9, Month::July, 2027))
.build()
.unwrap();
(
Swaption::new(
shared_mut(swap.into_fixed_vs_floating()),
european(Date::new(7, Month::July, 2027)),
if cash {
SettlementType::Cash
} else {
SettlementType::Physical
},
if cash {
SettlementMethod::ParYieldCurve
} else {
SettlementMethod::PhysicalOTC
},
settings,
),
curve,
)
}
#[test]
fn implied_volatility_matches_independent_quantlib_prices() {
let cases = [
(
false,
false,
0.2,
0.0,
0.00025057295935918054,
0.0002557058884618845,
),
(
false,
true,
0.2,
0.0,
0.0002505730263925663,
0.00025570595686843367,
),
(
false,
false,
0.3,
0.01,
0.004793571124123376,
0.004891766319613821,
),
(
false,
true,
0.3,
0.01,
0.004793572406501582,
0.004891767628261249,
),
(
true,
false,
0.01,
0.0,
0.004228008592218679,
0.0043146183700853045,
),
(
true,
true,
0.01,
0.0,
0.004228009723297353,
0.004314619524333864,
),
];
for (normal, cash, expected, shift, spot, forward) in cases {
let (swaption, curve) = oracle_swaption(cash);
let vol_type = if normal {
VolatilityType::Normal
} else {
VolatilityType::ShiftedLognormal
};
for (target, price_type) in [
(spot, SwaptionPriceType::Spot),
(forward, SwaptionPriceType::Forward),
] {
let recovered = swaption
.implied_volatility(
target,
curve.clone(),
if normal { 0.02 } else { 0.1 },
1e-10,
100,
1e-7,
4.0,
vol_type,
shift,
price_type,
)
.unwrap();
assert!(
(recovered - expected).abs() < 1e-9,
"normal={normal} cash={cash} recovered={recovered}"
);
}
}
}
#[test]
fn implied_volatility_rejects_invalid_targets_bounds_and_preserves_engine_errors() {
let (swaption, curve) = oracle_swaption(false);
let solve = |target, curve, min, max, evaluations| {
swaption.implied_volatility(
target,
curve,
0.1,
1e-10,
evaluations,
min,
max,
VolatilityType::ShiftedLognormal,
0.0,
SwaptionPriceType::Spot,
)
};
for target in [-1.0, Real::NAN, Real::INFINITY, 100.0] {
assert!(solve(target, curve.clone(), 1e-7, 4.0, 100).is_err());
}
assert!(solve(0.001, curve.clone(), -1.0, 4.0, 100).is_err());
assert!(solve(0.001, curve.clone(), 1.0, 0.5, 100).is_err());
assert!(solve(0.001, curve.clone(), 1e-7, 4.0, 1).is_err());
let error = solve(0.001, Handle::empty(), 1e-7, 4.0, 100).unwrap_err();
assert!(
!error.message().contains("non-finite"),
"engine error must propagate: {error}"
);
}
#[test]
fn implied_volatility_rejects_expired_and_non_european_exercise() {
use crate::exercise::AmericanExercise;
let (mut swaption, curve) = oracle_swaption(false);
let solve = |s: &Swaption| {
s.implied_volatility(
0.001,
curve.clone(),
0.1,
1e-8,
100,
1e-7,
4.0,
VolatilityType::ShiftedLognormal,
0.0,
SwaptionPriceType::Spot,
)
};
swaption.exercise = shared(
AmericanExercise::new(
Date::new(7, Month::July, 2026),
Date::new(7, Month::July, 2027),
false,
)
.unwrap(),
);
assert_eq!(
solve(&swaption).unwrap_err().message(),
"not a European option"
);
swaption.exercise = european(Date::new(6, Month::July, 2026));
assert_eq!(
solve(&swaption).unwrap_err().message(),
"instrument expired"
);
}
#[test]
fn implied_volatility_preserves_installed_swaption_engine() {
let (mut swaption, curve) = oracle_swaption(false);
let engine: SharedMut<dyn PricingEngine> = shared_mut(BlackSwaptionEngine::with_flat_vol(
curve.clone(),
Handle::new(shared(SimpleQuote::new(0.2)) as Shared<dyn Quote>),
Actual365Fixed::new(),
0.0,
CashAnnuityModel::DiscountCurve,
Shared::clone(&swaption.settings),
));
swaption
.base_mut()
.set_pricing_engine(SharedMut::clone(&engine));
let before = swaption.npv().unwrap();
let vol = swaption
.implied_volatility(
0.004793571124123376,
curve,
0.1,
1e-10,
100,
1e-7,
4.0,
VolatilityType::ShiftedLognormal,
0.01,
SwaptionPriceType::Spot,
)
.unwrap();
assert!((vol - 0.3).abs() < 1e-9);
assert!(SharedMut::ptr_eq(
swaption.base().pricing_engine().unwrap(),
&engine
));
assert!((swaption.npv().unwrap() - before).abs() < 1e-14);
}
}