use std::cell::Cell;
use std::cell::RefCell;
use std::rc::Weak;
use crate::cashflows::RateAveraging;
use crate::errors::QlResult;
use crate::handle::{Handle, RelinkableHandle};
use crate::indexes::OvernightIndex;
use crate::indexes::iborindex::IborIndex;
use crate::indexes::index::Index;
use crate::indexes::interestrateindex::InterestRateIndex;
use crate::instrument::Instrument;
use crate::instruments::{
FuturesType, MakeOis, MakeVanillaSwap, OvernightIndexedSwap, VanillaSwap,
};
use crate::patterns::observable::{AsObservable, Observable};
use crate::quotes::{Quote, SimpleQuote};
use crate::settings::Settings;
use crate::shared::{Shared, shared};
use crate::termstructures::bootstraphelper::{
BootstrapHelperBase, RateHelper, RelativeDateRateHelper,
};
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::dategenerationrule::DateGeneration;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::time::{asx, imm};
use crate::types::{Integer, Natural, Real};
pub struct DepositRateHelper {
base: BootstrapHelperBase,
index: IborIndex,
term_structure_handle: RelinkableHandle<dyn YieldTermStructure>,
fixing_date: Cell<Date>,
}
impl DepositRateHelper {
pub fn new(quote: Handle<dyn Quote>, index: &IborIndex) -> Shared<DepositRateHelper> {
Self::build(quote, index)
}
pub fn from_rate(rate: Real, index: &IborIndex) -> Shared<DepositRateHelper> {
let quote = Handle::new(shared(SimpleQuote::new(rate)) as Shared<dyn Quote>);
Self::build(quote, index)
}
fn build(quote: Handle<dyn Quote>, source_index: &IborIndex) -> Shared<DepositRateHelper> {
let settings = source_index.base().settings().clone();
Shared::new_cyclic(|weak: &Weak<DepositRateHelper>| {
let weak = weak.clone();
let on_eval_change = Box::new(move || {
if let Some(helper) = weak.upgrade() {
helper.initialize_dates();
}
});
let term_structure_handle = RelinkableHandle::<dyn YieldTermStructure>::empty();
let index = source_index.clone_with(term_structure_handle.handle());
let base = BootstrapHelperBase::new_relative(quote, settings, true, on_eval_change);
let helper = DepositRateHelper {
base,
index,
term_structure_handle,
fixing_date: Cell::new(Date::null()),
};
helper.initialize_dates();
helper
})
}
}
impl AsObservable for DepositRateHelper {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl RateHelper for DepositRateHelper {
fn base(&self) -> &BootstrapHelperBase {
&self.base
}
fn implied_quote(&self) -> QlResult<Real> {
self.base.term_structure()?;
self.index.fixing(self.fixing_date.get(), true)
}
fn set_term_structure(&self, term_structure: &Shared<dyn YieldTermStructure>) {
self.term_structure_handle
.link_to_weak(Shared::downgrade(term_structure));
self.base.set_term_structure(term_structure);
}
}
impl RelativeDateRateHelper for DepositRateHelper {
fn initialize_dates(&self) {
let evaluation_date = self
.base
.evaluation_date()
.expect("a relative-date helper always tracks an evaluation date");
let reference_date = self
.index
.fixing_calendar()
.adjust(evaluation_date, BusinessDayConvention::Following);
let earliest = self
.index
.value_date(reference_date)
.expect("value date of an adjusted business day is valid");
self.fixing_date.set(self.index.fixing_date(earliest));
let maturity = self
.index
.maturity_date(earliest)
.expect("maturity date of a value date is valid");
self.base.set_earliest_date(earliest);
self.base.set_maturity_date(maturity);
self.base.set_pillar_date(maturity);
self.base.set_latest_date(maturity);
self.base.set_latest_relevant_date(maturity);
}
}
fn check_futures_date(date: Date, futures_type: FuturesType) -> QlResult<()> {
match futures_type {
FuturesType::Imm => {
crate::require!(
imm::is_imm_date(date, false),
"{date} is not a valid IMM date"
)
}
FuturesType::Asx => {
crate::require!(
asx::is_asx_date(date, false),
"{date} is not a valid ASX date"
)
}
FuturesType::Custom => {}
}
Ok(())
}
fn determine_maturity(
start: Date,
end: Option<Date>,
next_date: impl Fn(Date) -> Date,
) -> QlResult<Date> {
match end {
None => Ok(next_date(next_date(next_date(start)))),
Some(end) => {
crate::require!(
end > start,
"end date ({end}) must be greater than start date ({start})"
);
Ok(end)
}
}
}
pub struct FuturesRateHelper {
base: BootstrapHelperBase,
year_fraction: Real,
conv_adj: Handle<dyn Quote>,
}
impl FuturesRateHelper {
#[allow(clippy::too_many_arguments)]
pub fn new(
price: Handle<dyn Quote>,
ibor_start_date: Date,
length_in_months: Natural,
calendar: Calendar,
convention: BusinessDayConvention,
end_of_month: bool,
day_counter: DayCounter,
conv_adj: Handle<dyn Quote>,
futures_type: FuturesType,
) -> QlResult<Shared<FuturesRateHelper>> {
check_futures_date(ibor_start_date, futures_type)?;
let earliest = ibor_start_date;
let maturity = calendar.advance(
ibor_start_date,
length_in_months as Integer,
TimeUnit::Months,
convention,
end_of_month,
);
let year_fraction = day_counter.year_fraction_ref(earliest, maturity, earliest, maturity);
Ok(Self::assemble(
price,
conv_adj,
earliest,
maturity,
year_fraction,
))
}
pub fn from_end_date(
price: Handle<dyn Quote>,
ibor_start_date: Date,
ibor_end_date: Option<Date>,
day_counter: DayCounter,
conv_adj: Handle<dyn Quote>,
futures_type: FuturesType,
) -> QlResult<Shared<FuturesRateHelper>> {
check_futures_date(ibor_start_date, futures_type)?;
let maturity = match futures_type {
FuturesType::Imm => {
determine_maturity(ibor_start_date, ibor_end_date, |d| imm::next_date(d, false))?
}
FuturesType::Asx => {
determine_maturity(ibor_start_date, ibor_end_date, |d| asx::next_date(d, false))?
}
FuturesType::Custom => match ibor_end_date {
Some(end) => end,
None => crate::fail!("a Custom futures helper requires an explicit end date"),
},
};
let earliest = ibor_start_date;
let year_fraction = day_counter.year_fraction_ref(earliest, maturity, earliest, maturity);
Ok(Self::assemble(
price,
conv_adj,
earliest,
maturity,
year_fraction,
))
}
pub fn from_index(
price: Handle<dyn Quote>,
ibor_start_date: Date,
index: &IborIndex,
conv_adj: Handle<dyn Quote>,
futures_type: FuturesType,
) -> QlResult<Shared<FuturesRateHelper>> {
check_futures_date(ibor_start_date, futures_type)?;
let earliest = ibor_start_date;
let maturity = index.fixing_calendar().advance_by_period(
ibor_start_date,
index.tenor(),
index.business_day_convention(),
false,
);
let year_fraction = index
.day_counter()
.year_fraction_ref(earliest, maturity, earliest, maturity);
Ok(Self::assemble(
price,
conv_adj,
earliest,
maturity,
year_fraction,
))
}
fn assemble(
price: Handle<dyn Quote>,
conv_adj: Handle<dyn Quote>,
earliest: Date,
maturity: Date,
year_fraction: Real,
) -> Shared<FuturesRateHelper> {
let base = BootstrapHelperBase::new(price);
conv_adj.register_observer(&base.observer());
base.set_earliest_date(earliest);
base.set_maturity_date(maturity);
base.set_pillar_date(maturity);
base.set_latest_date(maturity);
base.set_latest_relevant_date(maturity);
shared(FuturesRateHelper {
base,
year_fraction,
conv_adj,
})
}
pub fn convexity_adjustment(&self) -> QlResult<Real> {
if self.conv_adj.is_empty() {
Ok(0.0)
} else {
self.conv_adj.current_link()?.value()
}
}
}
impl AsObservable for FuturesRateHelper {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl RateHelper for FuturesRateHelper {
fn base(&self) -> &BootstrapHelperBase {
&self.base
}
fn implied_quote(&self) -> QlResult<Real> {
let term_structure = self.base.term_structure()?;
let forward = (term_structure.discount_date(self.base.earliest_date(), false)?
/ term_structure.discount_date(self.base.maturity_date(), false)?
- 1.0)
/ self.year_fraction;
let future_rate = forward + self.convexity_adjustment()?;
Ok(100.0 * (1.0 - future_rate))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pillar {
MaturityDate,
LastRelevantDate,
}
pub struct FraRateHelper {
base: BootstrapHelperBase,
index: IborIndex,
term_structure_handle: RelinkableHandle<dyn YieldTermStructure>,
period_to_start: Option<Period>,
use_indexed_coupon: bool,
pillar: Pillar,
fixing_date: Cell<Date>,
spanning_time: Cell<Real>,
}
impl FraRateHelper {
pub fn new(
quote: Handle<dyn Quote>,
period_to_start: Period,
index: &IborIndex,
use_indexed_coupon: bool,
pillar: Pillar,
) -> Shared<FraRateHelper> {
Self::build(
quote,
Some(period_to_start),
index,
use_indexed_coupon,
pillar,
None,
)
}
pub fn from_rate(
rate: Real,
period_to_start: Period,
index: &IborIndex,
use_indexed_coupon: bool,
pillar: Pillar,
) -> Shared<FraRateHelper> {
let quote = Handle::new(shared(SimpleQuote::new(rate)) as Shared<dyn Quote>);
Self::new(quote, period_to_start, index, use_indexed_coupon, pillar)
}
pub fn from_months(
quote: Handle<dyn Quote>,
months_to_start: Natural,
index: &IborIndex,
use_indexed_coupon: bool,
pillar: Pillar,
) -> Shared<FraRateHelper> {
Self::new(
quote,
Period::new(months_to_start as Integer, TimeUnit::Months),
index,
use_indexed_coupon,
pillar,
)
}
pub fn from_dates(
quote: Handle<dyn Quote>,
start_date: Date,
end_date: Date,
index: &IborIndex,
use_indexed_coupon: bool,
pillar: Pillar,
) -> Shared<FraRateHelper> {
Self::build(
quote,
None,
index,
use_indexed_coupon,
pillar,
Some((start_date, end_date)),
)
}
fn build(
quote: Handle<dyn Quote>,
period_to_start: Option<Period>,
source_index: &IborIndex,
use_indexed_coupon: bool,
pillar: Pillar,
explicit_dates: Option<(Date, Date)>,
) -> Shared<FraRateHelper> {
let settings = source_index.base().settings().clone();
let update_dates = explicit_dates.is_none();
Shared::new_cyclic(|weak: &Weak<FraRateHelper>| {
let weak = weak.clone();
let on_eval_change = Box::new(move || {
if let Some(helper) = weak.upgrade() {
helper.initialize_dates();
}
});
let term_structure_handle = RelinkableHandle::<dyn YieldTermStructure>::empty();
let index = source_index.clone_with(term_structure_handle.handle());
let base =
BootstrapHelperBase::new_relative(quote, settings, update_dates, on_eval_change);
let helper = FraRateHelper {
base,
index,
term_structure_handle,
period_to_start,
use_indexed_coupon,
pillar,
fixing_date: Cell::new(Date::null()),
spanning_time: Cell::new(0.0),
};
if let Some((start, end)) = explicit_dates {
helper.base.set_earliest_date(start);
helper.base.set_maturity_date(end);
}
helper.initialize_dates();
helper
})
}
}
impl AsObservable for FraRateHelper {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl RateHelper for FraRateHelper {
fn base(&self) -> &BootstrapHelperBase {
&self.base
}
fn implied_quote(&self) -> QlResult<Real> {
let term_structure = self.base.term_structure()?;
if self.use_indexed_coupon {
self.index.fixing(self.fixing_date.get(), true)
} else {
let discount_earliest =
term_structure.discount_date(self.base.earliest_date(), false)?;
let discount_maturity =
term_structure.discount_date(self.base.maturity_date(), false)?;
Ok((discount_earliest / discount_maturity - 1.0) / self.spanning_time.get())
}
}
fn set_term_structure(&self, term_structure: &Shared<dyn YieldTermStructure>) {
self.term_structure_handle
.link_to_weak(Shared::downgrade(term_structure));
self.base.set_term_structure(term_structure);
}
}
impl RelativeDateRateHelper for FraRateHelper {
fn initialize_dates(&self) {
if self.base.update_dates() {
let evaluation_date = self
.base
.evaluation_date()
.expect("a relative-date helper always tracks an evaluation date");
let calendar = self.index.fixing_calendar();
let reference = calendar.adjust(evaluation_date, BusinessDayConvention::Following);
let spot = self
.index
.value_date(reference)
.expect("spot date of an adjusted business day is valid");
let period_to_start = self
.period_to_start
.expect("a relative-date FRA helper carries a period to start");
let convention = self.index.business_day_convention();
let end_of_month = self.index.end_of_month();
let earliest =
calendar.advance_by_period(spot, period_to_start, convention, end_of_month);
let maturity = calendar.advance_by_period(
spot,
period_to_start + self.index.tenor(),
convention,
end_of_month,
);
self.base.set_earliest_date(earliest);
self.base.set_maturity_date(maturity);
}
let earliest = self.base.earliest_date();
let maturity = self.base.maturity_date();
let latest_relevant = if self.use_indexed_coupon {
self.index
.maturity_date(earliest)
.expect("maturity date of a value date is valid")
} else {
self.spanning_time
.set(self.index.day_counter().year_fraction(earliest, maturity));
maturity
};
self.base.set_latest_relevant_date(latest_relevant);
let pillar = match self.pillar {
Pillar::MaturityDate => maturity,
Pillar::LastRelevantDate => latest_relevant,
};
self.base.set_pillar_date(pillar);
self.base.set_latest_date(pillar);
self.fixing_date.set(self.index.fixing_date(earliest));
}
}
pub struct SwapRateHelper {
base: BootstrapHelperBase,
swap: RefCell<Option<VanillaSwap>>,
ibor_index: Shared<IborIndex>,
term_structure_handle: RelinkableHandle<dyn YieldTermStructure>,
discount_relinkable_handle: RelinkableHandle<dyn YieldTermStructure>,
discount_handle: Option<Handle<dyn YieldTermStructure>>,
spread: Handle<dyn Quote>,
settings: Shared<Settings<Date>>,
tenor: Period,
forward_start: Period,
calendar: Calendar,
fixed_frequency: Frequency,
fixed_convention: BusinessDayConvention,
fixed_day_count: DayCounter,
end_of_month: bool,
use_indexed_coupons: Option<bool>,
pillar: Pillar,
}
impl SwapRateHelper {
#[allow(clippy::too_many_arguments)]
pub fn new(
quote: Handle<dyn Quote>,
tenor: Period,
calendar: Calendar,
fixed_frequency: Frequency,
fixed_convention: BusinessDayConvention,
fixed_day_count: DayCounter,
ibor_index: &IborIndex,
) -> Shared<SwapRateHelper> {
Self::build(
quote,
tenor,
calendar,
fixed_frequency,
fixed_convention,
fixed_day_count,
ibor_index,
Handle::empty(),
Period::new(0, TimeUnit::Days),
None,
Pillar::LastRelevantDate,
)
}
#[allow(clippy::too_many_arguments)]
pub fn from_rate(
rate: Real,
tenor: Period,
calendar: Calendar,
fixed_frequency: Frequency,
fixed_convention: BusinessDayConvention,
fixed_day_count: DayCounter,
ibor_index: &IborIndex,
) -> Shared<SwapRateHelper> {
let quote = Handle::new(shared(SimpleQuote::new(rate)) as Shared<dyn Quote>);
Self::new(
quote,
tenor,
calendar,
fixed_frequency,
fixed_convention,
fixed_day_count,
ibor_index,
)
}
#[allow(clippy::too_many_arguments)]
pub fn with_details(
quote: Handle<dyn Quote>,
tenor: Period,
calendar: Calendar,
fixed_frequency: Frequency,
fixed_convention: BusinessDayConvention,
fixed_day_count: DayCounter,
ibor_index: &IborIndex,
spread: Handle<dyn Quote>,
forward_start: Period,
discounting_curve: Option<Handle<dyn YieldTermStructure>>,
pillar: Pillar,
) -> Shared<SwapRateHelper> {
Self::build(
quote,
tenor,
calendar,
fixed_frequency,
fixed_convention,
fixed_day_count,
ibor_index,
spread,
forward_start,
discounting_curve,
pillar,
)
}
#[allow(clippy::too_many_arguments)]
fn build(
quote: Handle<dyn Quote>,
tenor: Period,
calendar: Calendar,
fixed_frequency: Frequency,
fixed_convention: BusinessDayConvention,
fixed_day_count: DayCounter,
source_index: &IborIndex,
spread: Handle<dyn Quote>,
forward_start: Period,
discounting_curve: Option<Handle<dyn YieldTermStructure>>,
pillar: Pillar,
) -> Shared<SwapRateHelper> {
let settings = source_index.base().settings().clone();
Shared::new_cyclic(|weak: &Weak<SwapRateHelper>| {
let weak = weak.clone();
let on_eval_change = Box::new(move || {
if let Some(helper) = weak.upgrade() {
helper.initialize_dates();
}
});
let term_structure_handle = RelinkableHandle::<dyn YieldTermStructure>::empty();
let ibor_index = shared(source_index.clone_with(term_structure_handle.handle()));
let base = BootstrapHelperBase::new_relative(
quote,
Shared::clone(&settings),
true,
on_eval_change,
);
let helper = SwapRateHelper {
base,
swap: RefCell::new(None),
ibor_index,
term_structure_handle,
discount_relinkable_handle: RelinkableHandle::<dyn YieldTermStructure>::empty(),
discount_handle: discounting_curve,
spread,
settings,
tenor,
forward_start,
calendar,
fixed_frequency,
fixed_convention,
fixed_day_count,
end_of_month: false,
use_indexed_coupons: None,
pillar,
};
helper.initialize_dates();
helper
})
}
}
impl AsObservable for SwapRateHelper {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl RateHelper for SwapRateHelper {
fn base(&self) -> &BootstrapHelperBase {
&self.base
}
fn implied_quote(&self) -> QlResult<Real> {
self.base.term_structure()?;
let mut guard = self.swap.borrow_mut();
let swap = guard
.as_mut()
.expect("initialize_dates populates the swap at construction");
swap.recalculate()?;
const BASIS_POINT: Real = 1.0e-4;
let floating_leg_npv = swap.fixed_vs_floating_mut().floating_leg_npv()?;
let spread = if self.spread.is_empty() {
0.0
} else {
self.spread.current_link()?.value()?
};
let spread_npv = swap.fixed_vs_floating_mut().floating_leg_bps()? / BASIS_POINT * spread;
let total_npv = -(floating_leg_npv + spread_npv);
let fixed_leg_bps = swap.fixed_vs_floating_mut().fixed_leg_bps()?;
Ok(total_npv / (fixed_leg_bps / BASIS_POINT))
}
fn set_term_structure(&self, term_structure: &Shared<dyn YieldTermStructure>) {
self.term_structure_handle
.link_to_weak(Shared::downgrade(term_structure));
match &self.discount_handle {
Some(discount) if !discount.is_empty() => {
let curve = discount
.current_link()
.expect("a non-empty discount handle resolves");
self.discount_relinkable_handle
.link_to_weak(Shared::downgrade(&curve));
}
_ => self
.discount_relinkable_handle
.link_to_weak(Shared::downgrade(term_structure)),
}
self.base.set_term_structure(term_structure);
}
}
impl RelativeDateRateHelper for SwapRateHelper {
fn initialize_dates(&self) {
let fixed_tenor = if self.fixed_frequency == Frequency::Once {
self.tenor
} else {
Period::try_from(self.fixed_frequency)
.expect("a swap's fixed frequency maps to a valid period")
};
let swap = MakeVanillaSwap::new(
self.tenor,
Shared::clone(&self.ibor_index),
Some(0.0),
self.forward_start,
Shared::clone(&self.settings),
)
.with_discounting_term_structure(self.discount_relinkable_handle.handle())
.with_fixed_leg_day_count(self.fixed_day_count.clone())
.with_fixed_leg_tenor(fixed_tenor)
.with_fixed_leg_convention(self.fixed_convention)
.with_fixed_leg_termination_date_convention(self.fixed_convention)
.with_fixed_leg_calendar(self.calendar.clone())
.with_fixed_leg_end_of_month(self.end_of_month)
.with_floating_leg_calendar(self.calendar.clone())
.with_floating_leg_end_of_month(self.end_of_month)
.with_indexed_coupons(self.use_indexed_coupons)
.build()
.expect("a 0% fixed-rate swap with a valid evaluation date builds without pricing");
let base = swap.fixed_vs_floating();
let earliest = base
.fixed_schedule()
.start_date()
.min(base.floating_schedule().start_date());
let maturity = base
.fixed_schedule()
.end_date()
.max(base.floating_schedule().end_date());
let latest_relevant = maturity;
self.base.set_earliest_date(earliest);
self.base.set_maturity_date(maturity);
self.base.set_latest_relevant_date(latest_relevant);
let pillar = match self.pillar {
Pillar::MaturityDate => maturity,
Pillar::LastRelevantDate => latest_relevant,
};
self.base.set_pillar_date(pillar);
self.base.set_latest_date(pillar);
*self.swap.borrow_mut() = Some(swap);
}
}
pub struct OISRateHelper {
base: BootstrapHelperBase,
swap: RefCell<Option<OvernightIndexedSwap>>,
overnight_index: Shared<OvernightIndex>,
term_structure_handle: RelinkableHandle<dyn YieldTermStructure>,
discount_relinkable_handle: RelinkableHandle<dyn YieldTermStructure>,
discount_handle: Option<Handle<dyn YieldTermStructure>>,
overnight_spread: Handle<dyn Quote>,
settings: Shared<Settings<Date>>,
settlement_days: Natural,
tenor: Period,
forward_start: Period,
payment_lag: Integer,
payment_convention: BusinessDayConvention,
payment_frequency: Frequency,
averaging_method: RateAveraging,
pillar: Pillar,
}
impl OISRateHelper {
#[allow(clippy::too_many_arguments)]
pub fn new(
settlement_days: Natural,
tenor: Period,
quote: Handle<dyn Quote>,
overnight_index: &OvernightIndex,
discounting_curve: Option<Handle<dyn YieldTermStructure>>,
payment_lag: Integer,
payment_convention: BusinessDayConvention,
payment_frequency: Frequency,
forward_start: Period,
overnight_spread: Handle<dyn Quote>,
pillar: Pillar,
averaging_method: RateAveraging,
settings: Shared<Settings<Date>>,
) -> Shared<OISRateHelper> {
Shared::new_cyclic(|weak: &Weak<OISRateHelper>| {
let weak = weak.clone();
let on_eval_change = Box::new(move || {
if let Some(helper) = weak.upgrade() {
helper.initialize_dates();
}
});
let term_structure_handle = RelinkableHandle::<dyn YieldTermStructure>::empty();
let cloned_index = overnight_index.clone_with(term_structure_handle.handle());
let base = BootstrapHelperBase::new_relative(
quote,
Shared::clone(&settings),
true,
on_eval_change,
);
let helper = OISRateHelper {
base,
swap: RefCell::new(None),
overnight_index: cloned_index,
term_structure_handle,
discount_relinkable_handle: RelinkableHandle::<dyn YieldTermStructure>::empty(),
discount_handle: discounting_curve,
overnight_spread,
settings,
settlement_days,
tenor,
forward_start,
payment_lag,
payment_convention,
payment_frequency,
averaging_method,
pillar,
};
helper.initialize_dates();
helper
})
}
}
impl AsObservable for OISRateHelper {
fn observable(&self) -> &Observable {
self.base.observable()
}
}
impl RateHelper for OISRateHelper {
fn base(&self) -> &BootstrapHelperBase {
&self.base
}
fn implied_quote(&self) -> QlResult<Real> {
self.base.term_structure()?;
let mut guard = self.swap.borrow_mut();
let swap = guard
.as_mut()
.expect("initialize_dates populates the swap at construction");
swap.recalculate()?;
const BASIS_POINT: Real = 1.0e-4;
let overnight_leg_npv = swap.overnight_leg_npv()?;
let spread = if self.overnight_spread.is_empty() {
0.0
} else {
self.overnight_spread.current_link()?.value()?
};
let spread_npv = swap.overnight_leg_bps()? / BASIS_POINT * spread;
let total_npv = -(overnight_leg_npv + spread_npv);
let fixed_leg_bps = swap.fixed_vs_floating_mut().fixed_leg_bps()?;
Ok(total_npv / (fixed_leg_bps / BASIS_POINT))
}
fn set_term_structure(&self, term_structure: &Shared<dyn YieldTermStructure>) {
self.term_structure_handle
.link_to_weak(Shared::downgrade(term_structure));
match &self.discount_handle {
Some(discount) if !discount.is_empty() => {
let curve = discount
.current_link()
.expect("a non-empty discount handle resolves");
self.discount_relinkable_handle
.link_to_weak(Shared::downgrade(&curve));
}
_ => self
.discount_relinkable_handle
.link_to_weak(Shared::downgrade(term_structure)),
}
self.base.set_term_structure(term_structure);
}
}
impl RelativeDateRateHelper for OISRateHelper {
fn initialize_dates(&self) {
let swap = MakeOis::new(
self.tenor,
Shared::clone(&self.overnight_index),
Some(0.0),
self.forward_start,
Shared::clone(&self.settings),
)
.with_discounting_term_structure(self.discount_relinkable_handle.handle())
.with_telescopic_value_dates(false)
.with_payment_lag(self.payment_lag)
.with_payment_adjustment(self.payment_convention)
.with_payment_frequency(self.payment_frequency)
.with_averaging_method(self.averaging_method)
.with_lookback_days(None)
.with_lockout_days(0)
.with_rule(DateGeneration::Backward)
.with_convention(BusinessDayConvention::ModifiedFollowing)
.with_termination_date_convention(BusinessDayConvention::ModifiedFollowing)
.with_observation_shift(false)
.with_settlement_days(self.settlement_days)
.build()
.expect("a 0% fixed-rate OIS with benign deferred knobs builds without pricing");
let base_swap = swap.fixed_vs_floating();
let earliest = swap
.overnight_schedule()
.start_date()
.min(base_swap.fixed_schedule().start_date());
let maturity = swap
.overnight_schedule()
.end_date()
.max(base_swap.fixed_schedule().end_date());
let last_overnight_payment = swap.overnight_leg().last().map_or(maturity, |cf| cf.date());
let last_fixed_payment = base_swap
.fixed_leg()
.last()
.map_or(maturity, |cf| cf.date());
let latest_relevant = maturity.max(last_overnight_payment).max(last_fixed_payment);
self.base.set_earliest_date(earliest);
self.base.set_maturity_date(maturity);
self.base.set_latest_relevant_date(latest_relevant);
self.base.set_latest_date(latest_relevant);
let pillar = match self.pillar {
Pillar::MaturityDate => maturity,
Pillar::LastRelevantDate => latest_relevant,
};
self.base.set_pillar_date(pillar);
*self.swap.borrow_mut() = Some(swap);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::interestrate::Compounding;
use crate::math::interpolations::loglinear::LogLinear;
use crate::settings::Settings;
use crate::termstructures::bootstraptraits::Discount;
use crate::termstructures::yields::FlatForward;
use crate::termstructures::yields::PiecewiseYieldCurve;
use crate::test_support::{Flag, as_observer};
use crate::time::calendars::target::Target;
use crate::time::date::{Date, Month};
use crate::time::daycounters::actual360::Actual360;
use crate::time::frequency::Frequency;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::{currency::Currency, types::Rate};
fn settings_on(today: Date) -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
settings
}
fn euribor(
tenor: Period,
forwarding: Handle<dyn YieldTermStructure>,
settings: Shared<Settings<Date>>,
) -> IborIndex {
IborIndex::new(
"Euribor".into(),
tenor,
2,
Currency::eur(),
Target::new(),
BusinessDayConvention::Following,
false,
Actual360::new(),
forwarding,
settings,
)
}
fn flat_curve(reference: Date, rate: Rate) -> Shared<dyn YieldTermStructure> {
shared(FlatForward::with_rate(
reference,
rate,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>
}
fn today() -> Date {
Date::new(15, Month::June, 2026)
}
#[test]
fn implied_quote_matches_closed_form_deposit_rate() {
let settings = settings_on(today());
let source = euribor(Period::new(6, TimeUnit::Months), Handle::empty(), settings);
let helper = DepositRateHelper::from_rate(0.02, &source);
let rate = 0.03;
let curve = flat_curve(today(), rate);
helper.set_term_structure(&curve);
let d1 = helper.earliest_date();
let d2 = helper.maturity_date();
let t = Actual360::new().year_fraction(d1, d2);
let implied = helper.implied_quote().unwrap();
let closed_form = ((rate * t).exp() - 1.0) / t;
assert!((implied - closed_form).abs() < 1e-12);
}
#[test]
fn initialize_dates_follows_the_index_conventions() {
let settings = settings_on(today());
let source = euribor(Period::new(6, TimeUnit::Months), Handle::empty(), settings);
let helper = DepositRateHelper::from_rate(0.02, &source);
let reference = source
.fixing_calendar()
.adjust(today(), BusinessDayConvention::Following);
let earliest = source.value_date(reference).unwrap();
let maturity = source.maturity_date(earliest).unwrap();
assert_eq!(helper.earliest_date(), earliest);
assert!(earliest > today(), "the value date is spot, past today");
assert_eq!(helper.maturity_date(), maturity);
assert_eq!(helper.pillar_date(), maturity);
assert_eq!(helper.latest_relevant_date(), maturity);
}
#[test]
fn helper_prices_off_its_own_handle_not_the_source_index() {
let settings = settings_on(today());
let source = euribor(Period::new(6, TimeUnit::Months), Handle::empty(), settings);
let helper = DepositRateHelper::from_rate(0.02, &source);
let curve = flat_curve(today(), 0.03);
helper.set_term_structure(&curve);
let implied_low = helper.implied_quote().unwrap();
let curve_high = flat_curve(today(), 0.06);
helper.set_term_structure(&curve_high);
let implied_high = helper.implied_quote().unwrap();
assert!(
implied_high > implied_low,
"relinking the helper's handle moves its implied quote"
);
assert!(
source.forecast_fixing(helper.earliest_date()).is_err(),
"the source index's own empty handle is untouched"
);
}
#[test]
fn quote_error_is_market_minus_implied() {
let settings = settings_on(today());
let source = euribor(Period::new(6, TimeUnit::Months), Handle::empty(), settings);
let helper = DepositRateHelper::from_rate(0.05, &source);
let curve = flat_curve(today(), 0.03);
helper.set_term_structure(&curve);
let implied = helper.implied_quote().unwrap();
assert!((helper.quote_error().unwrap() - (0.05 - implied)).abs() < 1e-15);
}
#[test]
fn evaluation_date_change_reinitializes_dates() {
let settings = settings_on(today());
let source = euribor(
Period::new(6, TimeUnit::Months),
Handle::empty(),
settings.clone(),
);
let helper = DepositRateHelper::from_rate(0.02, &source);
let before = helper.earliest_date();
let flag = Flag::new();
helper.observable().register_observer(&as_observer(&flag));
let moved = today() + 30;
settings.set_evaluation_date(moved);
assert!(Flag::is_up(&flag), "date change must notify observers");
assert!(
helper.earliest_date() > before,
"date change must rerun initialize_dates"
);
}
fn swap_setup() -> (Shared<Settings<Date>>, IborIndex) {
let settings = settings_on(today());
let source = euribor(
Period::new(6, TimeUnit::Months),
Handle::empty(),
settings.clone(),
);
(settings, source)
}
fn independent_swap(
source: &IborIndex,
tenor: Period,
calendar: Calendar,
convention: BusinessDayConvention,
curve: &Shared<dyn YieldTermStructure>,
settings: Shared<Settings<Date>>,
) -> VanillaSwap {
let curve_handle = Handle::new(Shared::clone(curve));
let index = shared(source.clone_with(curve_handle.clone()));
MakeVanillaSwap::new(
tenor,
index,
Some(0.0),
Period::new(0, TimeUnit::Days),
settings,
)
.with_discounting_term_structure(curve_handle)
.with_fixed_leg_day_count(Actual360::new())
.with_fixed_leg_tenor(Period::try_from(Frequency::Annual).unwrap())
.with_fixed_leg_convention(convention)
.with_fixed_leg_termination_date_convention(convention)
.with_fixed_leg_calendar(calendar.clone())
.with_fixed_leg_end_of_month(false)
.with_floating_leg_calendar(calendar)
.with_floating_leg_end_of_month(false)
.build()
.unwrap()
}
#[test]
fn implied_quote_matches_fair_rate_of_the_same_swap() {
let (settings, source) = swap_setup();
let tenor = Period::new(5, TimeUnit::Years);
let calendar = Target::new();
let convention = BusinessDayConvention::ModifiedFollowing;
let helper = SwapRateHelper::from_rate(
0.02,
tenor,
calendar.clone(),
Frequency::Annual,
convention,
Actual360::new(),
&source,
);
let curve = flat_curve(today(), 0.03);
helper.set_term_structure(&curve);
let implied = helper.implied_quote().unwrap();
let mut independent =
independent_swap(&source, tenor, calendar, convention, &curve, settings);
let fair = independent.fixed_vs_floating_mut().fair_rate().unwrap();
assert!(
(implied - fair).abs() < 1e-12,
"implied {implied} vs fair {fair}"
);
}
#[test]
fn nonzero_spread_shifts_the_implied_quote_by_the_bps_ratio() {
let (settings, source) = swap_setup();
let tenor = Period::new(5, TimeUnit::Years);
let calendar = Target::new();
let convention = BusinessDayConvention::ModifiedFollowing;
let curve = flat_curve(today(), 0.03);
let helper0 = SwapRateHelper::from_rate(
0.02,
tenor,
calendar.clone(),
Frequency::Annual,
convention,
Actual360::new(),
&source,
);
helper0.set_term_structure(&curve);
let implied0 = helper0.implied_quote().unwrap();
let spread = 0.001;
let spread_handle = Handle::new(shared(SimpleQuote::new(spread)) as Shared<dyn Quote>);
let helper_s = SwapRateHelper::with_details(
Handle::new(shared(SimpleQuote::new(0.02)) as Shared<dyn Quote>),
tenor,
calendar.clone(),
Frequency::Annual,
convention,
Actual360::new(),
&source,
spread_handle,
Period::new(0, TimeUnit::Days),
None,
Pillar::LastRelevantDate,
);
helper_s.set_term_structure(&curve);
let implied_s = helper_s.implied_quote().unwrap();
assert!(
(implied_s - implied0).abs() > 1e-8,
"the spread must move the implied quote"
);
let mut independent =
independent_swap(&source, tenor, calendar, convention, &curve, settings);
let floating_bps = independent
.fixed_vs_floating_mut()
.floating_leg_bps()
.unwrap();
let fixed_bps = independent.fixed_vs_floating_mut().fixed_leg_bps().unwrap();
let expected = implied0 - spread * floating_bps / fixed_bps;
assert!(
(implied_s - expected).abs() < 1e-12,
"implied_s {implied_s} vs expected {expected}"
);
}
#[test]
fn moving_the_curve_updates_the_quote_without_notifying_the_helper() {
let (_settings, source) = swap_setup();
let tenor = Period::new(5, TimeUnit::Years);
let helper = SwapRateHelper::from_rate(
0.02,
tenor,
Target::new(),
Frequency::Annual,
BusinessDayConvention::ModifiedFollowing,
Actual360::new(),
&source,
);
let quote = shared(SimpleQuote::new(0.03));
let curve: Shared<dyn YieldTermStructure> = shared(FlatForward::new(
today(),
Handle::new(Shared::clone("e) as Shared<dyn Quote>),
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
));
helper.set_term_structure(&curve);
let implied_before = helper.implied_quote().unwrap();
let flag = Flag::new();
helper.observable().register_observer(&as_observer(&flag));
quote.set_value(0.05);
assert!(
!Flag::is_up(&flag),
"the helper must not observe the bootstrapping curve"
);
let implied_after = helper.implied_quote().unwrap();
assert!(
(implied_after - implied_before).abs() > 1e-6,
"the forced recalculation must surface the curve move without a notification"
);
}
#[test]
fn initialize_dates_spot_starts_and_pillar_follows_the_choice() {
let (_settings, source) = swap_setup();
let tenor = Period::new(5, TimeUnit::Years);
let helper = SwapRateHelper::with_details(
Handle::new(shared(SimpleQuote::new(0.02)) as Shared<dyn Quote>),
tenor,
Target::new(),
Frequency::Annual,
BusinessDayConvention::ModifiedFollowing,
Actual360::new(),
&source,
Handle::empty(),
Period::new(0, TimeUnit::Days),
None,
Pillar::MaturityDate,
);
assert!(
helper.earliest_date() > today(),
"the swap starts spot, past today"
);
assert!(helper.maturity_date() > helper.earliest_date());
assert_eq!(
helper.pillar_date(),
helper.maturity_date(),
"the MaturityDate pillar equals the maturity"
);
}
#[test]
fn swap_quote_error_is_market_minus_implied() {
let (_settings, source) = swap_setup();
let tenor = Period::new(5, TimeUnit::Years);
let helper = SwapRateHelper::from_rate(
0.05,
tenor,
Target::new(),
Frequency::Annual,
BusinessDayConvention::ModifiedFollowing,
Actual360::new(),
&source,
);
let curve = flat_curve(today(), 0.03);
helper.set_term_structure(&curve);
let implied = helper.implied_quote().unwrap();
assert!((helper.quote_error().unwrap() - (0.05 - implied)).abs() < 1e-15);
}
const ESTR_SWAP_DATA: [(i32, TimeUnit, Real); 33] = [
(1, TimeUnit::Weeks, 1.245),
(2, TimeUnit::Weeks, 1.269),
(3, TimeUnit::Weeks, 1.277),
(1, TimeUnit::Months, 1.281),
(2, TimeUnit::Months, 1.18),
(3, TimeUnit::Months, 1.143),
(4, TimeUnit::Months, 1.125),
(5, TimeUnit::Months, 1.116),
(6, TimeUnit::Months, 1.111),
(7, TimeUnit::Months, 1.109),
(8, TimeUnit::Months, 1.111),
(9, TimeUnit::Months, 1.117),
(10, TimeUnit::Months, 1.129),
(11, TimeUnit::Months, 1.141),
(12, TimeUnit::Months, 1.153),
(15, TimeUnit::Months, 1.218),
(18, TimeUnit::Months, 1.308),
(21, TimeUnit::Months, 1.407),
(2, TimeUnit::Years, 1.510),
(3, TimeUnit::Years, 1.916),
(4, TimeUnit::Years, 2.254),
(5, TimeUnit::Years, 2.523),
(6, TimeUnit::Years, 2.746),
(7, TimeUnit::Years, 2.934),
(8, TimeUnit::Years, 3.092),
(9, TimeUnit::Years, 3.231),
(10, TimeUnit::Years, 3.380),
(11, TimeUnit::Years, 3.457),
(12, TimeUnit::Years, 3.544),
(15, TimeUnit::Years, 3.702),
(20, TimeUnit::Years, 3.703),
(25, TimeUnit::Years, 3.541),
(30, TimeUnit::Years, 3.369),
];
#[test]
fn ois_bootstrap_reprices_the_quotes() {
use crate::indexes::ibor::Estr;
use crate::math::interpolations::loglinear::LogLinear;
use crate::termstructures::bootstraptraits::Discount;
use crate::termstructures::yields::PiecewiseYieldCurve;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
const PAYMENT_LAG: Integer = 2;
let today = Date::new(5, Month::February, 2009);
let settings = settings_on(today);
let calendar = Target::new();
let settlement = calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let estr = Estr::new(Handle::empty(), settings.clone());
let mut instruments: Vec<Shared<dyn RateHelper>> = Vec::new();
for (n, unit, rate) in ESTR_SWAP_DATA {
let quote = Handle::new(shared(SimpleQuote::new(rate / 100.0)) as Shared<dyn Quote>);
let helper = OISRateHelper::new(
2,
Period::new(n, unit),
quote,
&estr,
None,
PAYMENT_LAG,
BusinessDayConvention::Following,
Frequency::Annual,
Period::new(0, TimeUnit::Days),
Handle::empty(),
Pillar::LastRelevantDate,
RateAveraging::Compound,
settings.clone(),
);
instruments.push(helper as Shared<dyn RateHelper>);
}
let curve = PiecewiseYieldCurve::<Discount, LogLinear>::new(
today,
instruments,
Actual365Fixed::new(),
LogLinear,
)
.unwrap();
let handle: Handle<dyn YieldTermStructure> =
Handle::new(Shared::clone(&curve) as Shared<dyn YieldTermStructure>);
for (n, unit, rate) in ESTR_SWAP_DATA {
let priced_estr = shared(Estr::new(handle.clone(), settings.clone()));
let mut swap = MakeOis::new(
Period::new(n, unit),
priced_estr,
Some(0.0),
Period::new(0, TimeUnit::Days),
settings.clone(),
)
.with_effective_date(settlement)
.with_nominal(100.0)
.with_payment_lag(PAYMENT_LAG)
.with_discounting_term_structure(handle.clone())
.with_averaging_method(RateAveraging::Compound)
.build()
.unwrap();
let calculated = swap.fixed_vs_floating_mut().fair_rate().unwrap();
let expected = rate / 100.0;
assert!(
(calculated - expected).abs() < 1.0e-8,
"{n} {unit:?} OIS: calculated {calculated} vs expected {expected}"
);
}
}
fn quote_handle(value: Real) -> Handle<dyn Quote> {
Handle::new(shared(SimpleQuote::new(value)) as Shared<dyn Quote>)
}
#[test]
fn convexity_adjustment_shifts_the_bootstrapped_forward() {
use crate::math::interpolations::loglinear::LogLinear;
use crate::termstructures::bootstraptraits::Discount;
use crate::termstructures::yields::PiecewiseYieldCurve;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
let reference = today();
let imm_start = imm::next_date(reference, false);
let price = 96.0;
let c = 0.001;
let forward_from = |conv_adj: Handle<dyn Quote>| -> Real {
let helper = FuturesRateHelper::from_end_date(
quote_handle(price),
imm_start,
None,
Actual360::new(),
conv_adj,
FuturesType::Imm,
)
.unwrap();
let earliest = helper.earliest_date();
let maturity = helper.maturity_date();
let instruments: Vec<Shared<dyn RateHelper>> = vec![helper as Shared<dyn RateHelper>];
let curve = PiecewiseYieldCurve::<Discount, LogLinear>::new(
reference,
instruments,
Actual365Fixed::new(),
LogLinear,
)
.unwrap();
let disc_e = curve.discount_date(earliest, false).unwrap();
let disc_m = curve.discount_date(maturity, false).unwrap();
let yf = Actual360::new().year_fraction_ref(earliest, maturity, earliest, maturity);
(disc_e / disc_m - 1.0) / yf
};
let forward_0 = forward_from(Handle::empty());
let forward_c = forward_from(quote_handle(c));
assert!(
(forward_c - (forward_0 - c)).abs() < 1e-10,
"forward_c {forward_c} vs forward_0 - c {}",
forward_0 - c
);
}
#[test]
fn bootstrap_reprices_the_futures_quote() {
use crate::math::interpolations::loglinear::LogLinear;
use crate::termstructures::bootstraptraits::Discount;
use crate::termstructures::yields::PiecewiseYieldCurve;
use crate::time::daycounters::actual365fixed::Actual365Fixed;
let reference = today();
let imm_start = imm::next_date(reference, false);
let price = 96.0;
let c = 0.001;
let helper = FuturesRateHelper::from_end_date(
quote_handle(price),
imm_start,
None,
Actual360::new(),
quote_handle(c),
FuturesType::Imm,
)
.unwrap();
let earliest = helper.earliest_date();
let maturity = helper.maturity_date();
let instruments: Vec<Shared<dyn RateHelper>> =
vec![Shared::clone(&helper) as Shared<dyn RateHelper>];
let curve = PiecewiseYieldCurve::<Discount, LogLinear>::new(
reference,
instruments,
Actual365Fixed::new(),
LogLinear,
)
.unwrap();
let disc_e = curve.discount_date(earliest, false).unwrap();
let disc_m = curve.discount_date(maturity, false).unwrap();
let yf = Actual360::new().year_fraction_ref(earliest, maturity, earliest, maturity);
let forward = (disc_e / disc_m - 1.0) / yf;
let repriced = 100.0 * (1.0 - forward - c);
assert!(
(repriced - price).abs() < 1e-9,
"repriced {repriced} vs {price}"
);
let implied = helper.implied_quote().unwrap();
assert!(
(implied - price).abs() < 1e-9,
"implied {implied} vs {price}"
);
}
#[test]
fn from_end_date_advances_three_imm_periods_and_pins_the_schedule() {
let imm_start = imm::next_date(today(), false);
assert!(imm::is_imm_date(imm_start, false));
let helper = FuturesRateHelper::from_end_date(
quote_handle(96.0),
imm_start,
None,
Actual360::new(),
Handle::empty(),
FuturesType::Imm,
)
.unwrap();
let expected_maturity = imm::next_date(
imm::next_date(imm::next_date(imm_start, false), false),
false,
);
assert_eq!(helper.earliest_date(), imm_start);
assert_eq!(helper.maturity_date(), expected_maturity);
assert_eq!(helper.pillar_date(), expected_maturity);
assert_eq!(helper.latest_date(), expected_maturity);
assert_eq!(helper.latest_relevant_date(), expected_maturity);
}
#[test]
fn a_non_imm_start_is_rejected_under_the_imm_convention() {
let start = today();
assert!(
!imm::is_imm_date(start, false),
"the fixture start must not be an IMM date"
);
let result = FuturesRateHelper::from_end_date(
quote_handle(96.0),
start,
None,
Actual360::new(),
Handle::empty(),
FuturesType::Imm,
);
assert!(result.is_err());
}
#[test]
fn a_custom_helper_requires_an_explicit_end_date() {
let start = today();
let result = FuturesRateHelper::from_end_date(
quote_handle(96.0),
start,
None,
Actual360::new(),
Handle::empty(),
FuturesType::Custom,
);
assert!(result.is_err());
}
#[test]
fn from_index_takes_the_window_from_the_index_conventions() {
let settings = settings_on(today());
let index = euribor(Period::new(3, TimeUnit::Months), Handle::empty(), settings);
let imm_start = imm::next_date(today(), false);
let helper = FuturesRateHelper::from_index(
quote_handle(96.0),
imm_start,
&index,
Handle::empty(),
FuturesType::Imm,
)
.unwrap();
let expected_maturity = index.fixing_calendar().advance_by_period(
imm_start,
index.tenor(),
index.business_day_convention(),
false,
);
assert_eq!(helper.earliest_date(), imm_start);
assert_eq!(helper.maturity_date(), expected_maturity);
}
#[test]
fn fra_initialize_dates_advances_maturity_from_spot_not_chained() {
let eval = Date::new(13, Month::May, 2026);
let settings = settings_on(eval);
let index = euribor(Period::new(3, TimeUnit::Months), Handle::empty(), settings);
let period_to_start = Period::new(3, TimeUnit::Months);
let helper = FraRateHelper::from_rate(
0.03,
period_to_start,
&index,
true,
Pillar::LastRelevantDate,
);
let calendar = index.fixing_calendar();
let reference = calendar.adjust(eval, BusinessDayConvention::Following);
let spot = index.value_date(reference).unwrap();
let convention = index.business_day_convention();
let eom = index.end_of_month();
let earliest = calendar.advance_by_period(spot, period_to_start, convention, eom);
let maturity_from_spot =
calendar.advance_by_period(spot, period_to_start + index.tenor(), convention, eom);
let maturity_chained = calendar.advance_by_period(earliest, index.tenor(), convention, eom);
assert_ne!(
maturity_from_spot, maturity_chained,
"degenerate fixture: pick an evaluation date where the roll separates the two maturities"
);
assert_eq!(helper.earliest_date(), earliest);
assert_eq!(helper.maturity_date(), maturity_from_spot);
let latest_relevant = index.maturity_date(earliest).unwrap();
assert_eq!(helper.latest_relevant_date(), latest_relevant);
assert_eq!(helper.pillar_date(), latest_relevant);
assert_eq!(helper.latest_date(), latest_relevant);
}
#[test]
fn fra_from_dates_pins_explicit_window_and_ignores_eval_change() {
let settings = settings_on(today());
let index = euribor(
Period::new(3, TimeUnit::Months),
Handle::empty(),
settings.clone(),
);
let start = Date::new(15, Month::September, 2026);
let end = Date::new(15, Month::December, 2026);
let helper = FraRateHelper::from_dates(
Handle::new(shared(SimpleQuote::new(0.03)) as Shared<dyn Quote>),
start,
end,
&index,
false,
Pillar::MaturityDate,
);
assert_eq!(helper.earliest_date(), start);
assert_eq!(helper.maturity_date(), end);
assert_eq!(helper.latest_relevant_date(), end);
assert_eq!(helper.pillar_date(), end);
settings.set_evaluation_date(today() + 90);
assert_eq!(
helper.earliest_date(),
start,
"an explicit-date FRA must not shift on an evaluation-date change"
);
assert_eq!(helper.maturity_date(), end);
}
fn bootstrap_with_fra(
fra: Shared<dyn RateHelper>,
settings: Shared<Settings<Date>>,
today: Date,
) -> Handle<dyn YieldTermStructure> {
let calendar = Target::new();
let settlement = calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let deposit_index = euribor(
Period::new(3, TimeUnit::Months),
Handle::empty(),
settings.clone(),
);
let deposit = DepositRateHelper::from_rate(0.02, &deposit_index);
let curve = PiecewiseYieldCurve::<Discount, LogLinear>::new(
settlement,
vec![deposit as Shared<dyn RateHelper>, fra],
Actual360::new(),
LogLinear,
)
.unwrap();
Handle::new(Shared::clone(&curve) as Shared<dyn YieldTermStructure>)
}
fn reconstruct_window(index: &IborIndex, eval: Date, period_to_start: Period) -> (Date, Date) {
let calendar = index.fixing_calendar();
let reference = calendar.adjust(eval, BusinessDayConvention::Following);
let spot = index.value_date(reference).unwrap();
let convention = index.business_day_convention();
let eom = index.end_of_month();
let start = calendar.advance_by_period(spot, period_to_start, convention, eom);
let end =
calendar.advance_by_period(spot, period_to_start + index.tenor(), convention, eom);
(start, end)
}
#[test]
fn fra_indexed_bootstrap_reprices_the_input_rate() {
let eval = Date::new(13, Month::May, 2026);
let settings = settings_on(eval);
let period_to_start = Period::new(3, TimeUnit::Months);
let fra_rate = 0.03;
let fra_index = euribor(
Period::new(3, TimeUnit::Months),
Handle::empty(),
settings.clone(),
);
let fra = FraRateHelper::from_rate(
fra_rate,
period_to_start,
&fra_index,
true,
Pillar::LastRelevantDate,
);
let handle = bootstrap_with_fra(
Shared::clone(&fra) as Shared<dyn RateHelper>,
settings.clone(),
eval,
);
let reprice_index = euribor(
Period::new(3, TimeUnit::Months),
handle.clone(),
settings.clone(),
);
let (start, _end) = reconstruct_window(&reprice_index, eval, period_to_start);
let fixing_date = reprice_index.fixing_date(start);
let estimated = reprice_index.fixing(fixing_date, true).unwrap();
assert!(
(estimated - fra_rate).abs() <= 1.0e-9,
"indexed reprice {estimated} vs input {fra_rate}"
);
}
#[test]
fn fra_par_bootstrap_reprices_the_input_rate() {
let eval = Date::new(13, Month::May, 2026);
let settings = settings_on(eval);
let period_to_start = Period::new(3, TimeUnit::Months);
let fra_rate = 0.03;
let fra_index = euribor(
Period::new(3, TimeUnit::Months),
Handle::empty(),
settings.clone(),
);
let fra = FraRateHelper::from_rate(
fra_rate,
period_to_start,
&fra_index,
false,
Pillar::LastRelevantDate,
);
let handle = bootstrap_with_fra(
Shared::clone(&fra) as Shared<dyn RateHelper>,
settings.clone(),
eval,
);
let reprice_index = euribor(
Period::new(3, TimeUnit::Months),
handle.clone(),
settings.clone(),
);
let (start, end) = reconstruct_window(&reprice_index, eval, period_to_start);
let chained_end = reprice_index.fixing_calendar().advance_by_period(
start,
reprice_index.tenor(),
reprice_index.business_day_convention(),
reprice_index.end_of_month(),
);
assert_ne!(
end, chained_end,
"degenerate fixture: the maturity trap does not bite on this window, so the par reprice would not discriminate it"
);
let curve = handle.current_link().unwrap();
let discount_start = curve.discount_date(start, false).unwrap();
let discount_end = curve.discount_date(end, false).unwrap();
let tau = reprice_index.day_counter().year_fraction(start, end);
let estimated = (discount_start / discount_end - 1.0) / tau;
assert!(
(estimated - fra_rate).abs() <= 1.0e-9,
"par reprice {estimated} vs input {fra_rate}"
);
}
}