use crate::errors::QlResult;
use crate::event::event_has_occurred;
use crate::fail;
use crate::handle::Handle;
use crate::indexes::iborindex::IborIndex;
use crate::indexes::index::Index;
use crate::indexes::interestrateindex::InterestRateIndex;
use crate::instrument::{Instrument, InstrumentBase, InstrumentResults};
use crate::interestrate::{Compounding, InterestRate};
use crate::position::Position;
use crate::require;
use crate::settings::Settings;
use crate::shared::Shared;
use crate::termstructures::yieldtermstructure::YieldTermStructure;
use crate::time::businessdayconvention::BusinessDayConvention;
use crate::time::calendar::Calendar;
use crate::time::date::Date;
use crate::time::daycounter::DayCounter;
use crate::time::frequency::Frequency;
use crate::types::{Rate, Real};
pub struct ForwardRateAgreement {
base: InstrumentBase,
settings: Shared<Settings<Date>>,
fra_type: Position,
forward_rate: Option<InterestRate>,
strike_forward_rate: InterestRate,
notional_amount: Real,
index: Shared<IborIndex>,
use_indexed_coupon: bool,
day_counter: DayCounter,
calendar: Calendar,
business_day_convention: BusinessDayConvention,
value_date: Date,
maturity_date: Date,
discount_curve: Handle<dyn YieldTermStructure>,
amount: Option<Real>,
}
impl ForwardRateAgreement {
pub fn new(
index: Shared<IborIndex>,
value_date: Date,
fra_type: Position,
strike_forward_rate: Rate,
notional_amount: Real,
discount_curve: Handle<dyn YieldTermStructure>,
) -> QlResult<ForwardRateAgreement> {
let maturity_date = index.maturity_date(value_date)?;
let mut fra = Self::with_maturity(
index,
value_date,
maturity_date,
fra_type,
strike_forward_rate,
notional_amount,
discount_curve,
)?;
fra.use_indexed_coupon = true;
Ok(fra)
}
#[allow(clippy::neg_cmp_op_on_partial_ord)]
pub fn with_maturity(
index: Shared<IborIndex>,
value_date: Date,
maturity_date: Date,
fra_type: Position,
strike_forward_rate: Rate,
notional_amount: Real,
discount_curve: Handle<dyn YieldTermStructure>,
) -> QlResult<ForwardRateAgreement> {
let day_counter = index.day_counter().clone();
let calendar = index.fixing_calendar();
let business_day_convention = index.business_day_convention();
let maturity_date = calendar.adjust(maturity_date, business_day_convention);
require!(notional_amount > 0.0, "notionalAmount must be positive");
require!(
value_date < maturity_date,
"valueDate must be earlier than maturityDate"
);
let strike_forward_rate = InterestRate::new(
strike_forward_rate,
day_counter.clone(),
Compounding::Simple,
Frequency::Once,
)?;
let settings = index.base().settings().clone();
let base = InstrumentBase::new();
settings.register_eval_date_observer(&base.observer());
discount_curve.register_observer(&base.observer());
base.register_with(index.observable());
Ok(ForwardRateAgreement {
base,
settings,
fra_type,
forward_rate: None,
strike_forward_rate,
notional_amount,
index,
use_indexed_coupon: false,
day_counter,
calendar,
business_day_convention,
value_date,
maturity_date,
discount_curve,
amount: None,
})
}
pub fn calendar(&self) -> &Calendar {
&self.calendar
}
pub fn business_day_convention(&self) -> BusinessDayConvention {
self.business_day_convention
}
pub fn day_counter(&self) -> &DayCounter {
&self.day_counter
}
pub fn discount_curve(&self) -> &Handle<dyn YieldTermStructure> {
&self.discount_curve
}
pub fn fixing_date(&self) -> Date {
self.index.fixing_date(self.value_date)
}
pub fn amount(&mut self) -> QlResult<Real> {
self.calculate()?;
match self.amount {
Some(amount) => Ok(amount),
None => fail!("amount not provided"),
}
}
pub fn forward_rate(&mut self) -> QlResult<InterestRate> {
self.calculate()?;
match &self.forward_rate {
Some(rate) => Ok(rate.clone()),
None => self.calculated_forward_rate(),
}
}
fn calculated_forward_rate(&self) -> QlResult<InterestRate> {
let rate = if self.use_indexed_coupon {
self.index.fixing(self.fixing_date(), false)?
} else {
let curve = self.index.forwarding_term_structure().current_link()?;
(curve.discount_date(self.value_date, false)?
/ curve.discount_date(self.maturity_date, false)?
- 1.0)
/ self
.index
.day_counter()
.year_fraction(self.value_date, self.maturity_date)
};
InterestRate::new(
rate,
self.index.day_counter().clone(),
Compounding::Simple,
Frequency::Once,
)
}
fn calculate_amount(&mut self) -> QlResult<()> {
let forward = self.calculated_forward_rate()?;
let sign = match self.fra_type {
Position::Long => 1.0,
Position::Short => -1.0,
};
let f = forward.rate();
let k = self.strike_forward_rate.rate();
let t = forward
.day_counter()
.year_fraction(self.value_date, self.maturity_date);
self.amount = Some(self.notional_amount * sign * (f - k) * t / (1.0 + f * t));
self.forward_rate = Some(forward);
Ok(())
}
}
impl Instrument for ForwardRateAgreement {
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.value_date, &self.settings, None, None)
}
fn setup_expired(&mut self) {
let expired = InstrumentResults {
value: Some(0.0),
error_estimate: Some(0.0),
..InstrumentResults::default()
};
self.base_mut().store_results(&expired);
self.forward_rate = self.calculated_forward_rate().ok();
}
fn perform_calculations(&mut self) -> QlResult<()> {
self.calculate_amount()?;
let discount = if self.discount_curve.is_empty() {
self.index.forwarding_term_structure().clone()
} else {
self.discount_curve.clone()
};
let amount = self.amount.expect("calculate_amount just set it");
let npv = amount
* discount
.current_link()?
.discount_date(self.value_date, false)?;
let results = InstrumentResults {
value: Some(npv),
..InstrumentResults::default()
};
self.base_mut().store_results(&results);
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::handle::RelinkableHandle;
use crate::indexes::ibor::euribor::Euribor;
use crate::indexes::ibor::usdlibor::UsdLibor;
use crate::math::interpolations::linear::Linear;
use crate::quotes::{Quote, SimpleQuote};
use crate::shared::shared;
use crate::termstructures::bootstraphelper::RateHelper;
use crate::termstructures::bootstraptraits::{ForwardRate, ZeroYield};
use crate::termstructures::yields::{FlatForward, FraRateHelper, PiecewiseYieldCurve, Pillar};
use crate::time::calendars::target::Target;
use crate::time::date::Month;
use crate::time::daycounters::actual360::Actual360;
use crate::time::period::Period;
use crate::time::timeunit::TimeUnit;
use crate::types::{Integer, Natural};
fn settings_on(today: Date) -> Shared<Settings<Date>> {
let settings = shared(Settings::<Date>::new());
settings.set_evaluation_date(today);
settings
}
fn flat_curve(reference: Date, rate: Rate) -> Handle<dyn YieldTermStructure> {
Handle::new(shared(FlatForward::with_rate(
reference,
rate,
Actual360::new(),
Compounding::Continuous,
Frequency::Annual,
)) as Shared<dyn YieldTermStructure>)
}
#[test]
fn construction_without_a_curve() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let curve_handle: RelinkableHandle<dyn YieldTermStructure> = RelinkableHandle::empty();
let index = shared(
UsdLibor::new(
Period::new(3, TimeUnit::Months),
curve_handle.handle(),
settings.clone(),
)
.expect("a 3M USDLibor tenor is valid"),
);
let settlement_date = index.fixing_calendar().advance(
today,
index.fixing_days() as Integer,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let quotes = [
shared(SimpleQuote::default()),
shared(SimpleQuote::default()),
shared(SimpleQuote::default()),
];
let helpers: Vec<Shared<dyn RateHelper>> = quotes
.iter()
.enumerate()
.map(|(i, quote)| {
FraRateHelper::new(
Handle::new(Shared::clone(quote) as Shared<dyn Quote>),
Period::new(i as Integer + 1, TimeUnit::Years),
index.as_ref(),
true,
Pillar::LastRelevantDate,
) as Shared<dyn RateHelper>
})
.collect();
let curve = PiecewiseYieldCurve::<ForwardRate, Linear>::new(
today,
helpers,
index.day_counter().clone(),
Linear,
)
.unwrap();
curve_handle.link_to(curve as Shared<dyn YieldTermStructure>);
let mut fra = ForwardRateAgreement::new(
Shared::clone(&index),
settlement_date + Period::new(12, TimeUnit::Months),
Position::Long,
0.0,
1.0,
curve_handle.handle(),
)
.unwrap();
quotes[0].set_value(0.01);
quotes[1].set_value(0.02);
quotes[2].set_value(0.03);
let rate = fra.forward_rate().unwrap().rate();
assert!(
(rate - 0.01).abs() <= 1.0e-6,
"FRA without maturityDate: got rate {rate}, expected 0.01"
);
let mut fra2 = ForwardRateAgreement::with_maturity(
index,
settlement_date + Period::new(12, TimeUnit::Months),
settlement_date + Period::new(15, TimeUnit::Months),
Position::Long,
0.0,
1.0,
curve_handle.handle(),
)
.unwrap();
let rate2 = fra2.forward_rate().unwrap().rate();
assert!(
(rate2 - 0.01).abs() <= 1.0e-6,
"FRA with maturityDate: got rate {rate2}, expected 0.01"
);
}
#[test]
fn par_forward_rate_amount_and_npv_match_the_closed_form() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let forward_flat = 0.04;
let discount_flat = 0.06;
let index = shared(Euribor::three_months(
flat_curve(today, forward_flat),
settings,
));
let value_date = Date::new(17, Month::August, 2026);
let maturity_date = Date::new(17, Month::November, 2026);
let strike = 0.02;
let dc = Actual360::new();
let disc = |rate: Rate, d: Date| (-rate * dc.year_fraction(today, d)).exp();
let mut fra = ForwardRateAgreement::with_maturity(
Shared::clone(&index),
value_date,
maturity_date,
Position::Long,
strike,
100.0,
flat_curve(today, discount_flat),
)
.unwrap();
let expected_forward = (disc(forward_flat, value_date) / disc(forward_flat, maturity_date)
- 1.0)
/ dc.year_fraction(value_date, maturity_date);
let forward = fra.forward_rate().unwrap();
assert!((forward.rate() - expected_forward).abs() < 1.0e-12);
assert_eq!(forward.compounding(), Compounding::Simple);
let t = dc.year_fraction(value_date, maturity_date);
let expected_amount =
100.0 * (expected_forward - strike) * t / (1.0 + expected_forward * t);
assert!((fra.amount().unwrap() - expected_amount).abs() < 1.0e-12);
let expected_npv = expected_amount * disc(discount_flat, value_date);
assert!((fra.npv().unwrap() - expected_npv).abs() < 1.0e-12);
let mut short_fra = ForwardRateAgreement::with_maturity(
Shared::clone(&index),
value_date,
maturity_date,
Position::Short,
strike,
100.0,
flat_curve(today, discount_flat),
)
.unwrap();
assert!((short_fra.amount().unwrap() + expected_amount).abs() < 1.0e-12);
let mut undiscounted = ForwardRateAgreement::with_maturity(
index,
value_date,
maturity_date,
Position::Long,
strike,
100.0,
Handle::empty(),
)
.unwrap();
let expected_fallback_npv = expected_amount * disc(forward_flat, value_date);
assert!(
(undiscounted.npv().unwrap() - expected_fallback_npv).abs() < 1.0e-12,
"an empty discount handle must fall back to the forwarding curve"
);
}
#[test]
fn constructor_guards_notional_and_date_order() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(today);
let index = shared(Euribor::three_months(flat_curve(today, 0.04), settings));
let err = ForwardRateAgreement::with_maturity(
Shared::clone(&index),
Date::new(17, Month::August, 2026),
Date::new(17, Month::November, 2026),
Position::Long,
0.02,
0.0,
Handle::empty(),
)
.err()
.unwrap();
assert_eq!(err.message(), "notionalAmount must be positive");
let err = ForwardRateAgreement::with_maturity(
index,
Date::new(17, Month::November, 2026),
Date::new(17, Month::August, 2026),
Position::Long,
0.02,
100.0,
Handle::empty(),
)
.err()
.unwrap();
assert_eq!(err.message(), "valueDate must be earlier than maturityDate");
}
#[test]
fn expired_fra_has_zero_npv_but_still_a_forward_rate() {
let today = Date::new(15, Month::June, 2026);
let settings = settings_on(Date::new(1, Month::December, 2026));
let index = shared(Euribor::three_months(flat_curve(today, 0.04), settings));
let value_date = Date::new(17, Month::August, 2026);
let maturity_date = Date::new(17, Month::November, 2026);
let mut fra = ForwardRateAgreement::with_maturity(
index,
value_date,
maturity_date,
Position::Long,
0.02,
100.0,
Handle::empty(),
)
.unwrap();
assert!(fra.is_expired().unwrap());
assert_eq!(fra.npv().unwrap(), 0.0);
let dc = Actual360::new();
let disc = |d: Date| (-0.04 * dc.year_fraction(today, d)).exp();
let expected_forward = (disc(value_date) / disc(maturity_date) - 1.0)
/ dc.year_fraction(value_date, maturity_date);
assert!((fra.forward_rate().unwrap().rate() - expected_forward).abs() < 1.0e-12);
let err = fra.amount().unwrap_err();
assert_eq!(err.message(), "amount not provided");
}
#[test]
fn par_fra_regression() {
let fra_data: [(Integer, Rate); 5] =
[(1, 4.581), (2, 4.573), (3, 4.557), (6, 4.496), (9, 4.490)];
let calendar = Target::new();
let today = calendar.adjust(
Date::new(23, Month::February, 2023),
BusinessDayConvention::Following,
);
let settings = settings_on(today);
let settlement = calendar.advance(
today,
2,
TimeUnit::Days,
BusinessDayConvention::Following,
false,
);
let helper_index = Euribor::three_months(Handle::empty(), settings.clone());
let helpers: Vec<Shared<dyn RateHelper>> = fra_data
.iter()
.map(|&(n, rate)| {
FraRateHelper::from_months(
Handle::new(shared(SimpleQuote::new(rate / 100.0)) as Shared<dyn Quote>),
n as Natural,
&helper_index,
false,
Pillar::LastRelevantDate,
) as Shared<dyn RateHelper>
})
.collect();
let curve_handle: RelinkableHandle<dyn YieldTermStructure> = RelinkableHandle::empty();
let curve = PiecewiseYieldCurve::<ZeroYield, Linear>::new(
settlement,
helpers,
Actual360::new(),
Linear,
)
.unwrap();
curve_handle.link_to(curve as Shared<dyn YieldTermStructure>);
let euribor3m = shared(Euribor::three_months(curve_handle.handle(), settings));
for (i, &(n, rate)) in fra_data.iter().enumerate() {
let start = calendar.advance(
settlement,
n,
TimeUnit::Months,
euribor3m.business_day_convention(),
euribor3m.end_of_month(),
);
let end = calendar.advance(
settlement,
3 + n,
TimeUnit::Months,
euribor3m.business_day_convention(),
euribor3m.end_of_month(),
);
let mut fra = ForwardRateAgreement::with_maturity(
Shared::clone(&euribor3m),
start,
end,
Position::Long,
rate / 100.0,
100.0,
curve_handle.handle(),
)
.unwrap();
let expected_rate = rate / 100.0;
let estimated_rate = fra.forward_rate().unwrap().rate();
assert!(
(expected_rate - estimated_rate).abs() <= 1.0e-6,
"FRA {} (at par) failure: estimated {estimated_rate}, expected {expected_rate}",
i + 1
);
}
}
}