use crate::discretizedasset::DiscretizedAsset;
use crate::errors::QlResult;
use crate::instrument::InstrumentResults;
use crate::instruments::{SettlementMethod, SwaptionArguments, SwaptionEngine};
use crate::math::timegrid::TimeGrid;
use crate::methods::lattices::lattice::Lattice;
use crate::models::model::CalibratedModelHolder;
use crate::models::shortrate::hullwhite::HullWhite;
use crate::patterns::observable::{AsObservable, Observable};
use crate::pricingengine::{Arguments, PricingEngine, Results};
use crate::settings::Settings;
use crate::shared::{Shared, SharedMut, shared};
use crate::time::date::Date;
use crate::types::{Size, Time};
use crate::{fail, require};
use super::DiscretizedSwaption;
pub struct TreeSwaptionEngine {
base: SwaptionEngine,
model: SharedMut<HullWhite>,
time_steps: Size,
settings: Shared<Settings<Date>>,
}
impl TreeSwaptionEngine {
pub fn new(
model: SharedMut<HullWhite>,
time_steps: Size,
settings: Shared<Settings<Date>>,
) -> QlResult<TreeSwaptionEngine> {
require!(
time_steps > 0,
"timeSteps must be positive, {time_steps} not allowed"
);
let base = SwaptionEngine::new(SwaptionArguments::default(), InstrumentResults::default());
base.register_with(model.borrow().calibrated_model().observable());
Ok(TreeSwaptionEngine {
base,
model,
time_steps,
settings,
})
}
}
impl AsObservable for TreeSwaptionEngine {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl PricingEngine for TreeSwaptionEngine {
fn arguments_mut(&mut self) -> &mut dyn Arguments {
self.base.arguments_mut()
}
fn results(&self) -> &dyn Results {
self.base.results()
}
fn reset(&mut self) {
self.base.reset();
}
fn calculate(&mut self) -> QlResult<()> {
require!(
self.base.arguments().settlement_method != SettlementMethod::ParYieldCurve,
"cash settled (ParYieldCurve) swaptions not priced with TreeSwaptionEngine"
);
let model = self.model.borrow();
let (reference_date, day_counter) = {
let curve = model.term_structure().current_link()?;
(curve.reference_date()?, curve.require_day_counter()?)
};
let (mut swaption, stopping_times) = {
let args = self.base.arguments();
let Some(exercise) = args.exercise.as_ref() else {
fail!("exercise not set");
};
let stopping_times: Vec<Time> = exercise
.dates()
.iter()
.map(|&date| day_counter.year_fraction(reference_date, date))
.collect();
let swaption =
DiscretizedSwaption::new(args, reference_date, &day_counter, &self.settings)?;
(swaption, stopping_times)
};
let times = swaption.mandatory_times();
let grid = TimeGrid::with_mandatory_times(×, self.time_steps)?;
let lattice: Shared<dyn Lattice> = shared(model.tree(grid)?);
drop(model);
let Some(&last) = stopping_times.last() else {
fail!("swaption has no exercise dates");
};
swaption.initialize(Shared::clone(&lattice), last)?;
let Some(next_exercise) = stopping_times.iter().copied().find(|&t| t >= 0.0) else {
fail!("swaption has no non-negative exercise time");
};
swaption.rollback(next_exercise)?;
self.base.results_mut().value = Some(swaption.present_value()?);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::any::Any;
use crate::exercise::{EuropeanExercise, Exercise, ExerciseType};
use crate::handle::Handle;
use crate::indexes::IborIndex;
use crate::indexes::ibor::Euribor;
use crate::instrument::Instrument;
use crate::instruments::{
FixedVsFloatingSwap, FixedVsFloatingSwapArguments, SettlementType, SwapType, Swaption,
VanillaSwap,
};
use crate::interestrate::Compounding;
use crate::pricingengines::DiscountingSwapEngine;
use crate::pricingengines::swaption::JamshidianSwaptionEngine;
use crate::shared::{shared, shared_mut};
use crate::termstructures::yields::FlatForward;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
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::schedule::{MakeSchedule, Schedule};
use crate::time::timeunit::TimeUnit;
use crate::types::{Rate, Real};
const A: Real = 0.05;
const SIGMA: Real = 0.01;
const NOMINAL: Real = 100.0;
const FIXED_RATE: Real = 0.03;
fn settings() -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, Month::January, 2026));
settings
}
fn flat_curve() -> Handle<dyn YieldTermStructure> {
Handle::new(shared(FlatForward::with_rate(
Date::new(15, Month::January, 2026),
0.03,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
}
fn hw_model() -> SharedMut<HullWhite> {
HullWhite::new(flat_curve(), A, SIGMA).unwrap()
}
fn schedule(from: Date, to: Date, frequency: Frequency) -> Schedule {
MakeSchedule::new()
.from(from)
.to(to)
.with_frequency(frequency)
.with_calendar(Target::new())
.with_convention(BusinessDayConvention::Unadjusted)
.with_termination_date_convention(BusinessDayConvention::Unadjusted)
.forwards()
.end_of_month(false)
.build()
}
fn conv_swap(settings: &Shared<Settings<Date>>, swap_type: SwapType) -> FixedVsFloatingSwap {
let index: Shared<IborIndex> =
shared(Euribor::six_months(flat_curve(), Shared::clone(settings)));
VanillaSwap::new(
swap_type,
NOMINAL,
schedule(
Date::new(15, Month::January, 2028),
Date::new(15, Month::January, 2033),
Frequency::Annual,
),
FIXED_RATE,
Thirty360::with_convention(Convention::BondBasis),
schedule(
Date::new(15, Month::January, 2028),
Date::new(15, Month::January, 2033),
Frequency::Semiannual,
),
index,
0.0,
Actual360::new(),
None,
Shared::clone(settings),
)
.unwrap()
.into_fixed_vs_floating()
}
fn european(date: Date) -> Shared<dyn Exercise> {
shared(EuropeanExercise::new(date)) as Shared<dyn Exercise>
}
struct StubExercise {
exercise_type: ExerciseType,
dates: Vec<Date>,
}
impl Exercise for StubExercise {
fn exercise_type(&self) -> ExerciseType {
self.exercise_type
}
fn dates(&self) -> &[Date] {
&self.dates
}
}
fn jamshidian_npv(swap_type: SwapType) -> Real {
let settings = settings();
let swap = shared_mut(conv_swap(&settings, swap_type));
let mut swaption = Swaption::new(
swap,
european(Date::new(15, Month::January, 2027)),
SettlementType::Physical,
SettlementMethod::PhysicalOTC,
Shared::clone(&settings),
);
let engine =
shared_mut(JamshidianSwaptionEngine::new(hw_model())) as SharedMut<dyn PricingEngine>;
swaption.base_mut().set_pricing_engine(engine);
swaption.npv().unwrap()
}
fn tree_npv(swap_type: SwapType, steps: Size, exercise: Shared<dyn Exercise>) -> Real {
let settings = settings();
let swap = shared_mut(conv_swap(&settings, swap_type));
let mut swaption = Swaption::new(
swap,
exercise,
SettlementType::Physical,
SettlementMethod::PhysicalOTC,
Shared::clone(&settings),
);
let engine = shared_mut(TreeSwaptionEngine::new(hw_model(), steps, settings).unwrap())
as SharedMut<dyn PricingEngine>;
swaption.base_mut().set_pricing_engine(engine);
swaption.npv().unwrap()
}
#[test]
fn tree_converges_to_jamshidian_european() {
let reference = jamshidian_npv(SwapType::Payer);
let exercise = || european(Date::new(15, Month::January, 2027));
let e_coarse = (tree_npv(SwapType::Payer, 50, exercise()) - reference).abs() / reference;
let e_fine = (tree_npv(SwapType::Payer, 1100, exercise()) - reference).abs() / reference;
assert!(
e_fine < 1.0e-3,
"payer: tree(1100) rel err {e_fine} vs jamshidian {reference}"
);
assert!(
e_fine < e_coarse,
"payer: error must shrink 50->1100: coarse {e_coarse} fine {e_fine}"
);
let reference_r = jamshidian_npv(SwapType::Receiver);
let e_r = (tree_npv(SwapType::Receiver, 300, exercise()) - reference_r).abs() / reference_r;
assert!(
e_r < 5.0e-3,
"receiver: tree(300) rel err {e_r} vs jamshidian {reference_r}"
);
}
#[test]
fn bermudan_dominates_european() {
let euro = tree_npv(
SwapType::Payer,
120,
european(Date::new(15, Month::January, 2027)),
);
let bermudan = tree_npv(
SwapType::Payer,
120,
shared(StubExercise {
exercise_type: ExerciseType::Bermudan,
dates: vec![
Date::new(15, Month::January, 2027),
Date::new(15, Month::January, 2028),
Date::new(15, Month::January, 2029),
],
}) as Shared<dyn Exercise>,
);
assert!(
bermudan >= euro - 1.0e-9,
"bermudan {bermudan} must dominate european {euro}"
);
}
#[test]
fn rejects_non_positive_time_steps() {
let err = TreeSwaptionEngine::new(hw_model(), 0, settings())
.err()
.expect("time_steps == 0 must be rejected");
assert_eq!(err.message(), "timeSteps must be positive, 0 not allowed");
}
#[test]
fn rejects_par_yield_cash_settlement() {
let mut engine = TreeSwaptionEngine::new(hw_model(), 50, settings()).unwrap();
let args = (engine.arguments_mut() as &mut dyn Any)
.downcast_mut::<SwaptionArguments>()
.expect("engine carries SwaptionArguments");
args.settlement_method = SettlementMethod::ParYieldCurve;
assert_eq!(
engine.calculate().unwrap_err().message(),
"cash settled (ParYieldCurve) swaptions not priced with TreeSwaptionEngine"
);
}
const BERM_A: Real = 0.048696;
const BERM_SIGMA: Real = 0.0058904;
const BERM_NOMINAL: Real = 1000.0;
fn berm_curve(settlement: Date) -> Handle<dyn YieldTermStructure> {
Handle::new(shared(FlatForward::with_rate(
settlement,
0.04875825,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
}
fn berm_swap(
settings: &Shared<Settings<Date>>,
calendar: &Calendar,
settlement: Date,
curve: &Handle<dyn YieldTermStructure>,
fixed_rate: Rate,
) -> SharedMut<FixedVsFloatingSwap> {
let start = calendar.advance(
settlement,
1,
TimeUnit::Years,
BusinessDayConvention::Following,
false,
);
let maturity = calendar.advance(
start,
5,
TimeUnit::Years,
BusinessDayConvention::Following,
false,
);
let fixed_schedule = MakeSchedule::new()
.from(start)
.to(maturity)
.with_frequency(Frequency::Annual)
.with_calendar(calendar.clone())
.with_convention(BusinessDayConvention::Unadjusted)
.with_termination_date_convention(BusinessDayConvention::Unadjusted)
.forwards()
.end_of_month(false)
.build();
let float_schedule = MakeSchedule::new()
.from(start)
.to(maturity)
.with_frequency(Frequency::Semiannual)
.with_calendar(calendar.clone())
.with_convention(BusinessDayConvention::ModifiedFollowing)
.with_termination_date_convention(BusinessDayConvention::ModifiedFollowing)
.forwards()
.end_of_month(false)
.build();
let index: Shared<IborIndex> =
shared(Euribor::six_months(curve.clone(), Shared::clone(settings)));
let swap = shared_mut(
VanillaSwap::new(
SwapType::Payer,
BERM_NOMINAL,
fixed_schedule,
fixed_rate,
Thirty360::with_convention(Convention::BondBasis),
float_schedule,
index,
0.0,
Actual360::new(),
None,
Shared::clone(settings),
)
.unwrap()
.into_fixed_vs_floating(),
);
let engine = shared_mut(DiscountingSwapEngine::new(
curve.clone(),
None,
None,
None,
Shared::clone(settings),
)) as SharedMut<dyn PricingEngine>;
swap.borrow_mut().base_mut().set_pricing_engine(engine);
swap
}
fn berm_swaption_npv(
swap: SharedMut<FixedVsFloatingSwap>,
exercise_dates: Vec<Date>,
model: SharedMut<HullWhite>,
settings: &Shared<Settings<Date>>,
) -> Real {
let mut swaption = Swaption::new(
swap,
shared(StubExercise {
exercise_type: ExerciseType::Bermudan,
dates: exercise_dates,
}) as Shared<dyn Exercise>,
SettlementType::Physical,
SettlementMethod::PhysicalOTC,
Shared::clone(settings),
);
let engine =
shared_mut(TreeSwaptionEngine::new(model, 50, Shared::clone(settings)).unwrap())
as SharedMut<dyn PricingEngine>;
swaption.base_mut().set_pricing_engine(engine);
swaption.npv().unwrap()
}
#[test]
fn cached_bermudan_diagnostic() {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(Date::new(15, Month::February, 2002));
settings.set_using_at_par_coupons(false);
let calendar = Target::new();
let settlement = calendar.advance(
Date::new(15, Month::February, 2002),
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let curve = berm_curve(settlement);
let atm_rate = berm_swap(&settings, &calendar, settlement, &curve, 0.0)
.borrow_mut()
.fair_rate()
.unwrap();
let atm_swap = berm_swap(&settings, &calendar, settlement, &curve, atm_rate);
let mut atm_args = FixedVsFloatingSwapArguments::default();
atm_swap.borrow().setup_arguments(&mut atm_args).unwrap();
let exercise_dates = atm_args.fixed_reset_dates.clone();
assert_eq!(exercise_dates.len(), 5, "five annual fixed accrual starts");
let cases = [
("ITM", 0.8 * atm_rate, 42.2402_f64),
("ATM", atm_rate, 12.9032),
("OTM", 1.2 * atm_rate, 2.49758),
];
for (label, rate, cached) in cases {
let swap = berm_swap(&settings, &calendar, settlement, &curve, rate);
let model = HullWhite::new(curve.clone(), BERM_A, BERM_SIGMA).unwrap();
let npv = berm_swaption_npv(swap, exercise_dates.clone(), model, &settings);
let rel = (npv - cached).abs() / cached;
eprintln!(
"[cached-bermudan diagnostic] {label}: tree {npv:.6} vs cached {cached} (rel {rel:.3e})"
);
assert!(
rel < 1.0e-2,
"{label}: tree {npv} vs cached {cached} (rel {rel}) outside the loose diagnostic band"
);
}
}
}