use crate::discretizedasset::{
CouponAdjustment, DiscretizedAsset, DiscretizedAssetBase, DiscretizedDiscountBond,
};
use crate::errors::QlResult;
use crate::instruments::{FixedVsFloatingSwapArguments, SwapType};
use crate::math::array::Array;
use crate::settings::Settings;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::types::{Real, Size, Spread, Time};
use crate::{fail, require};
#[allow(clippy::float_cmp)]
fn is_reset_time_in_past(
reset_time: Time,
pay_time: Time,
include_todays_cash_flows: bool,
) -> bool {
reset_time < 0.0 && (pay_time > 0.0 || (include_todays_cash_flows && pay_time == 0.0))
}
pub struct DiscretizedSwap {
base: DiscretizedAssetBase,
swap_type: SwapType,
nominal: Option<Real>,
fixed_coupons: Vec<Real>,
floating_accrual_times: Vec<Time>,
floating_spreads: Vec<Spread>,
floating_coupons: Vec<Real>,
fixed_reset_times: Vec<Time>,
fixed_pay_times: Vec<Time>,
fixed_coupon_adjustments: Vec<CouponAdjustment>,
fixed_reset_is_in_past: Vec<bool>,
floating_reset_times: Vec<Time>,
floating_pay_times: Vec<Time>,
floating_coupon_adjustments: Vec<CouponAdjustment>,
floating_reset_is_in_past: Vec<bool>,
}
impl DiscretizedSwap {
pub fn new(
args: &FixedVsFloatingSwapArguments,
reference_date: Date,
day_counter: &DayCounter,
settings: &Settings<Date>,
) -> QlResult<Self> {
let fixed = vec![CouponAdjustment::Pre; args.fixed_pay_dates.len()];
let floating = vec![CouponAdjustment::Pre; args.floating_pay_dates.len()];
Self::with_adjustments(args, reference_date, day_counter, fixed, floating, settings)
}
#[allow(clippy::needless_range_loop)]
pub fn with_adjustments(
args: &FixedVsFloatingSwapArguments,
reference_date: Date,
day_counter: &DayCounter,
mut fixed_coupon_adjustments: Vec<CouponAdjustment>,
mut floating_coupon_adjustments: Vec<CouponAdjustment>,
settings: &Settings<Date>,
) -> QlResult<Self> {
require!(
fixed_coupon_adjustments.len() == args.fixed_pay_dates.len(),
"The fixed coupon adjustments must have the same size as the number of fixed coupons."
);
require!(
floating_coupon_adjustments.len() == args.floating_pay_dates.len(),
"The floating coupon adjustments must have the same size as the number of floating \
coupons."
);
let Some(swap_type) = args.swap_type else {
fail!("the swap type is not set on the arguments");
};
let include_todays = settings.include_todays_cash_flows() == Some(true);
let n_fixed = args.fixed_reset_dates.len();
let mut fixed_reset_times = Vec::with_capacity(n_fixed);
let mut fixed_pay_times = Vec::with_capacity(n_fixed);
let mut fixed_reset_is_in_past = Vec::with_capacity(n_fixed);
for i in 0..n_fixed {
let reset = day_counter.year_fraction(reference_date, args.fixed_reset_dates[i]);
let pay = day_counter.year_fraction(reference_date, args.fixed_pay_dates[i]);
let in_past = is_reset_time_in_past(reset, pay, include_todays);
fixed_reset_times.push(reset);
fixed_pay_times.push(pay);
fixed_reset_is_in_past.push(in_past);
if in_past {
fixed_coupon_adjustments[i] = CouponAdjustment::Post;
}
}
let n_float = args.floating_reset_dates.len();
let mut floating_reset_times = Vec::with_capacity(n_float);
let mut floating_pay_times = Vec::with_capacity(n_float);
let mut floating_reset_is_in_past = Vec::with_capacity(n_float);
for i in 0..n_float {
let reset = day_counter.year_fraction(reference_date, args.floating_reset_dates[i]);
let pay = day_counter.year_fraction(reference_date, args.floating_pay_dates[i]);
let in_past = is_reset_time_in_past(reset, pay, include_todays);
floating_reset_times.push(reset);
floating_pay_times.push(pay);
floating_reset_is_in_past.push(in_past);
if in_past {
floating_coupon_adjustments[i] = CouponAdjustment::Post;
}
}
Ok(DiscretizedSwap {
base: DiscretizedAssetBase::default(),
swap_type,
nominal: args.nominal,
fixed_coupons: args.fixed_coupons.clone(),
floating_accrual_times: args.floating_accrual_times.clone(),
floating_spreads: args.floating_spreads.clone(),
floating_coupons: args.floating_coupons.clone(),
fixed_reset_times,
fixed_pay_times,
fixed_coupon_adjustments,
fixed_reset_is_in_past,
floating_reset_times,
floating_pay_times,
floating_coupon_adjustments,
floating_reset_is_in_past,
})
}
fn add_fixed_coupon(&mut self, i: Size) -> QlResult<()> {
let method = self.require_method()?;
let time = self.time();
let pay_time = self.fixed_pay_times[i];
let mut bond = DiscretizedDiscountBond::new();
bond.initialize(method, pay_time)?;
bond.rollback(time)?;
let fixed_coupon = self.fixed_coupons[i];
let payer = matches!(self.swap_type, SwapType::Payer);
let values = self.values_mut();
for j in 0..values.size() {
let coupon = fixed_coupon * bond.values()[j];
if payer {
values[j] -= coupon;
} else {
values[j] += coupon;
}
}
Ok(())
}
fn add_floating_coupon(&mut self, i: Size) -> QlResult<()> {
let method = self.require_method()?;
let time = self.time();
let pay_time = self.floating_pay_times[i];
let mut bond = DiscretizedDiscountBond::new();
bond.initialize(method, pay_time)?;
bond.rollback(time)?;
let Some(nominal) = self.nominal else {
fail!("non-constant nominals are not supported yet");
};
let accrual = self.floating_accrual_times[i];
let spread = self.floating_spreads[i];
let accrued_spread = nominal * accrual * spread;
let payer = matches!(self.swap_type, SwapType::Payer);
let values = self.values_mut();
for j in 0..values.size() {
let bond_value = bond.values()[j];
let coupon = nominal * (1.0 - bond_value) + accrued_spread * bond_value;
if payer {
values[j] += coupon;
} else {
values[j] -= coupon;
}
}
Ok(())
}
}
impl DiscretizedAsset for DiscretizedSwap {
fn base(&self) -> &DiscretizedAssetBase {
&self.base
}
fn base_mut(&mut self) -> &mut DiscretizedAssetBase {
&mut self.base
}
fn as_asset_mut(&mut self) -> &mut dyn DiscretizedAsset {
self
}
fn reset(&mut self, size: Size) -> QlResult<()> {
*self.values_mut() = Array::filled(size, 0.0);
self.adjust_values()
}
#[allow(clippy::neg_cmp_op_on_partial_ord)]
fn mandatory_times(&self) -> Vec<Time> {
let mut times = Vec::new();
for &t in &self.fixed_reset_times {
if t >= 0.0 {
times.push(t);
}
}
for &t in &self.fixed_pay_times {
if t >= 0.0 {
times.push(t);
}
}
for &t in &self.floating_reset_times {
if t >= 0.0 {
times.push(t);
}
}
for &t in &self.floating_pay_times {
if t >= 0.0 {
times.push(t);
}
}
times
}
#[allow(clippy::neg_cmp_op_on_partial_ord)]
fn pre_adjust_values_impl(&mut self) -> QlResult<()> {
for i in 0..self.floating_reset_times.len() {
let t = self.floating_reset_times[i];
if self.floating_coupon_adjustments[i] == CouponAdjustment::Pre
&& t >= 0.0
&& self.is_on_time(t)
{
self.add_floating_coupon(i)?;
}
}
for i in 0..self.fixed_reset_times.len() {
let t = self.fixed_reset_times[i];
if self.fixed_coupon_adjustments[i] == CouponAdjustment::Pre
&& t >= 0.0
&& self.is_on_time(t)
{
self.add_fixed_coupon(i)?;
}
}
Ok(())
}
#[allow(clippy::neg_cmp_op_on_partial_ord)]
fn post_adjust_values_impl(&mut self) -> QlResult<()> {
for i in 0..self.floating_reset_times.len() {
let t = self.floating_reset_times[i];
if self.floating_coupon_adjustments[i] == CouponAdjustment::Post
&& t >= 0.0
&& self.is_on_time(t)
{
self.add_floating_coupon(i)?;
}
}
for i in 0..self.fixed_reset_times.len() {
let t = self.fixed_reset_times[i];
if self.fixed_coupon_adjustments[i] == CouponAdjustment::Post
&& t >= 0.0
&& self.is_on_time(t)
{
self.add_fixed_coupon(i)?;
}
}
let payer = matches!(self.swap_type, SwapType::Payer);
for i in 0..self.fixed_pay_times.len() {
if self.fixed_reset_is_in_past[i] && self.is_on_time(self.fixed_pay_times[i]) {
let fixed_coupon = self.fixed_coupons[i];
for v in self.values_mut().iter_mut() {
if payer {
*v -= fixed_coupon;
} else {
*v += fixed_coupon;
}
}
}
}
for i in 0..self.floating_pay_times.len() {
if self.floating_reset_is_in_past[i] && self.is_on_time(self.floating_pay_times[i]) {
let Some(¤t) = self.floating_coupons.get(i) else {
fail!("current floating coupon not given");
};
for v in self.values_mut().iter_mut() {
if payer {
*v += current;
} else {
*v -= current;
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::Handle;
use crate::indexes::ibor::Euribor;
use crate::instrument::Instrument;
use crate::instruments::VanillaSwap;
use crate::interestrate::Compounding;
use crate::math::timegrid::TimeGrid;
use crate::methods::lattices::lattice::Lattice;
use crate::models::shortrate::HullWhite;
use crate::pricingengine::PricingEngine;
use crate::pricingengines::DiscountingSwapEngine;
use crate::shared::{Shared, SharedMut, 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;
use crate::time::timeunit::TimeUnit;
use crate::types::{Integer, Rate, Real};
struct OneNodeLattice {
grid: TimeGrid,
}
impl Lattice for OneNodeLattice {
fn time_grid(&self) -> &TimeGrid {
&self.grid
}
fn initialize(&self, asset: &mut dyn DiscretizedAsset, time: Time) -> QlResult<()> {
asset.set_time(time);
asset.reset(1)
}
fn partial_rollback(&self, asset: &mut dyn DiscretizedAsset, to: Time) -> QlResult<()> {
asset.set_time(to);
Ok(())
}
fn rollback(&self, asset: &mut dyn DiscretizedAsset, to: Time) -> QlResult<()> {
self.partial_rollback(asset, to)?;
asset.adjust_values()
}
fn present_value(&self, asset: &mut dyn DiscretizedAsset) -> QlResult<Real> {
Ok(asset.values()[0])
}
fn grid(&self, _time: Time) -> QlResult<Array> {
Ok(Array::filled(1, 0.0))
}
}
fn fixed_only_args(
swap_type: SwapType,
reset: Date,
pay: Date,
coupon: Real,
) -> FixedVsFloatingSwapArguments {
FixedVsFloatingSwapArguments {
swap_type: Some(swap_type),
nominal: Some(100.0),
fixed_reset_dates: vec![reset],
fixed_pay_dates: vec![pay],
fixed_coupons: vec![coupon],
fixed_nominals: vec![100.0],
..FixedVsFloatingSwapArguments::default()
}
}
#[test]
fn mandatory_times_excludes_past_resets() {
let reference = Date::new(15, Month::January, 2026);
let dc = Actual365Fixed::new();
let settings = Settings::<Date>::new();
let args = fixed_only_args(
SwapType::Payer,
Date::new(15, Month::January, 2025),
Date::new(15, Month::January, 2027),
5.0,
);
let swap = DiscretizedSwap::new(&args, reference, &dc, &settings).unwrap();
let times = swap.mandatory_times();
assert_eq!(times.len(), 1, "only the future pay time survives");
assert!((times[0] - 1.0).abs() < 1e-12, "pay time is +1y");
}
#[test]
fn reset_applies_the_past_reset_coupon_at_its_pay_node() {
let reference = Date::new(15, Month::January, 2026);
let dc = Actual365Fixed::new();
let settings = Settings::<Date>::new();
let reset = Date::new(15, Month::January, 2025);
let pay = Date::new(15, Month::January, 2027);
let grid = TimeGrid::with_mandatory_times(&[1.0], 2).unwrap();
let pay_time = grid.back().unwrap();
for (swap_type, expected) in [(SwapType::Payer, -5.0), (SwapType::Receiver, 5.0)] {
let args = fixed_only_args(swap_type, reset, pay, 5.0);
let mut swap = DiscretizedSwap::new(&args, reference, &dc, &settings).unwrap();
let lattice: Shared<dyn Lattice> = shared(OneNodeLattice { grid: grid.clone() });
swap.initialize(Shared::clone(&lattice), pay_time).unwrap();
assert!(
(swap.values()[0] - expected).abs() < 1e-12,
"{swap_type}: past-reset coupon gives {} (want {expected})",
swap.values()[0]
);
}
}
struct Vars {
settings: Shared<Settings<Date>>,
calendar: Calendar,
settlement: Date,
curve: Handle<dyn YieldTermStructure>,
index: Shared<crate::indexes::IborIndex>,
}
impl Vars {
fn new() -> Vars {
let settings = shared(Settings::new());
settings.set_evaluation_date(Date::new(17, Month::June, 2002));
settings.set_using_at_par_coupons(false);
let calendar = Target::new();
let settlement = calendar.advance(
Date::new(17, Month::June, 2002),
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let curve: Handle<dyn YieldTermStructure> = Handle::new(shared(FlatForward::with_rate(
settlement,
0.05,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
))
as Shared<dyn YieldTermStructure>);
let index = shared(Euribor::six_months(curve.clone(), Shared::clone(&settings)));
Vars {
settings,
calendar,
settlement,
curve,
index,
}
}
fn make_swap(&self, swap_type: SwapType, length: Integer, rate: Rate) -> VanillaSwap {
let maturity = self.calendar.advance(
self.settlement,
length,
TimeUnit::Years,
BusinessDayConvention::ModifiedFollowing,
false,
);
let fixed_schedule = MakeSchedule::new()
.from(self.settlement)
.to(maturity)
.with_frequency(Frequency::Annual)
.with_calendar(self.calendar.clone())
.with_convention(BusinessDayConvention::Unadjusted)
.with_termination_date_convention(BusinessDayConvention::Unadjusted)
.forwards()
.end_of_month(false)
.build();
let float_schedule = MakeSchedule::new()
.from(self.settlement)
.to(maturity)
.with_frequency(Frequency::Semiannual)
.with_calendar(self.calendar.clone())
.with_convention(BusinessDayConvention::ModifiedFollowing)
.with_termination_date_convention(BusinessDayConvention::ModifiedFollowing)
.forwards()
.end_of_month(false)
.build();
let mut swap = VanillaSwap::new(
swap_type,
100.0,
fixed_schedule,
rate,
Thirty360::with_convention(Convention::BondBasis),
float_schedule,
Shared::clone(&self.index),
0.002,
Actual360::new(),
None,
Shared::clone(&self.settings),
)
.unwrap();
let engine = shared_mut(DiscountingSwapEngine::new(
self.curve.clone(),
None,
None,
None,
Shared::clone(&self.settings),
));
swap.base_mut()
.set_pricing_engine(engine as SharedMut<dyn PricingEngine>);
swap
}
}
fn tree_npv(
vars: &Vars,
swap: &VanillaSwap,
model_curve: Handle<dyn YieldTermStructure>,
) -> Real {
let mut args = FixedVsFloatingSwapArguments::default();
swap.setup_arguments(&mut args).unwrap();
let link = vars.curve.current_link().unwrap();
let reference_date = link.reference_date().unwrap();
let day_counter = link.day_counter().unwrap();
let mut dswap =
DiscretizedSwap::new(&args, reference_date, &day_counter, &vars.settings).unwrap();
let grid = TimeGrid::with_mandatory_times(&dswap.mandatory_times(), 40).unwrap();
let model = HullWhite::new(model_curve, 0.1, 0.01).unwrap();
let lattice: Shared<dyn Lattice> = shared(model.borrow().tree(grid.clone()).unwrap());
dswap
.initialize(Shared::clone(&lattice), grid.back().unwrap())
.unwrap();
dswap.rollback(0.0).unwrap();
dswap.present_value().unwrap()
}
#[test]
fn swap_on_tree_matches_the_analytic_npv() {
let vars = Vars::new();
let mut payer = vars.make_swap(SwapType::Payer, 3, 0.06);
let mut receiver = vars.make_swap(SwapType::Receiver, 3, 0.06);
let analytic_payer = payer.npv().unwrap();
let analytic_receiver = receiver.npv().unwrap();
assert!(
analytic_payer.abs() > 1.0,
"the anchor swap must be materially off-market, got {analytic_payer}"
);
let tree_payer = tree_npv(&vars, &payer, vars.curve.clone());
let tree_receiver = tree_npv(&vars, &receiver, vars.curve.clone());
let rel_payer = (tree_payer - analytic_payer).abs() / analytic_payer.abs();
let rel_receiver = (tree_receiver - analytic_receiver).abs() / analytic_receiver.abs();
assert!(
rel_payer < 1e-3,
"payer: tree {tree_payer} vs analytic {analytic_payer} (rel {rel_payer})"
);
assert!(
rel_receiver < 1e-3,
"receiver: tree {tree_receiver} vs analytic {analytic_receiver} (rel {rel_receiver})"
);
assert!(
(tree_payer + tree_receiver).abs() < 1e-9,
"payer and receiver tree NPVs must negate: {tree_payer} vs {tree_receiver}"
);
}
#[test]
fn a_tree_fit_to_the_wrong_curve_breaks_the_anchor() {
let vars = Vars::new();
let mut payer = vars.make_swap(SwapType::Payer, 3, 0.06);
let analytic = payer.npv().unwrap();
let wrong_curve: Handle<dyn YieldTermStructure> =
Handle::new(shared(FlatForward::with_rate(
vars.settlement,
0.02,
Actual365Fixed::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>);
let mispriced = tree_npv(&vars, &payer, wrong_curve);
assert!(
(mispriced - analytic).abs() / analytic.abs() > 1e-2,
"a mis-fit tree must diverge: {mispriced} vs analytic {analytic}"
);
}
}