Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 128 additions & 1 deletion src/markets/forex/quotes/forwardpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ impl Observable for FXForwardHelper {
mod tests {
use crate::error::Result;
use crate::markets::forex::quotes::forwardpoints::{FXForwardHelper, FXForwardQuote};
use crate::tests::common::{sample_fx_forward_helper, setup};
use crate::tests::common::{bloomberg_eurusd_fx_forward_helper, sample_fx_forward_helper, setup};
use crate::time::calendars::JointCalendar;
use crate::time::calendars::Target;
use crate::time::calendars::UnitedKingdom;
Expand Down Expand Up @@ -392,6 +392,133 @@ mod tests {
Ok(())
}

/// Far-leg settlement dates from the Bloomberg EUR/USD helper must match the screenshot.
#[test]
fn test_bloomberg_eurusd_settlement_dates() -> Result<()> {
let helper = bloomberg_eurusd_fx_forward_helper();
let cal = JointCalendar::new(vec![
Box::new(Target),
Box::new(UnitedStates::default()),
]);
let val = helper.valuation_date;

assert_eq!(
Period::ON.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 13).unwrap()
);
assert_eq!(
Period::TN.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()
);
assert_eq!(
Period::SN.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 15).unwrap()
);
assert_eq!(
Period::Weeks(1).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 21).unwrap()
);
assert_eq!(
Period::Weeks(2).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 28).unwrap()
);
assert_eq!(
Period::Months(2).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 7, 14).unwrap()
);
assert_eq!(
Period::Months(4).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
);

Ok(())
}

/// get_forward returns exact Bloomberg Fwds Bid values at each tenor's far-leg date.
#[test]
fn test_bloomberg_eurusd_get_forward_exact() -> Result<()> {
let helper = bloomberg_eurusd_fx_forward_helper();
let cal = JointCalendar::new(vec![
Box::new(Target),
Box::new(UnitedStates::default()),
]);

assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 5, 13).unwrap(), &cal)?,
Some(1.174188)
);
assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 5, 14).unwrap(), &cal)?,
Some(1.174244)
);
assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 5, 15).unwrap(), &cal)?,
Some(1.174354)
);
assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 5, 21).unwrap(), &cal)?,
Some(1.174682)
);
assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 5, 28).unwrap(), &cal)?,
Some(1.175068)
);
assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 7, 14).unwrap(), &cal)?,
Some(1.177487)
);
assert_eq!(
helper.get_forward(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), &cal)?,
Some(1.180255)
);

Ok(())
}

/// Linear interpolation between 1W and 2W Bloomberg tenors.
#[test]
fn test_bloomberg_eurusd_get_forward_interpolation() -> Result<()> {
let helper = bloomberg_eurusd_fx_forward_helper();
let cal = JointCalendar::new(vec![
Box::new(Target),
Box::new(UnitedStates::default()),
]);

// Midpoint between 1W (2026-05-21, 1.174682) and 2W (2026-05-28, 1.175068)
// Target: 2026-05-26 (Tuesday, 5 days after 1W, 2 days before 2W)
let target = NaiveDate::from_ymd_opt(2026, 5, 26).unwrap();
let result = helper.get_forward(target, &cal)?.unwrap();
let expected = 1.174682 + (1.175068 - 1.174682) * 5.0 / 7.0;
assert!((result - expected).abs() < 1e-10);

Ok(())
}

/// ON pre-spot forward is below TN (spot-date), consistent with
/// negative ON swap points in the Bloomberg screenshot.
#[test]
fn test_bloomberg_eurusd_on_below_tn() -> Result<()> {
let helper = bloomberg_eurusd_fx_forward_helper();
let cal = JointCalendar::new(vec![
Box::new(Target),
Box::new(UnitedStates::default()),
]);

let on_fwd = helper
.get_forward(NaiveDate::from_ymd_opt(2026, 5, 13).unwrap(), &cal)?
.unwrap();
let tn_fwd = helper
.get_forward(NaiveDate::from_ymd_opt(2026, 5, 14).unwrap(), &cal)?
.unwrap();

assert!(
on_fwd < tn_fwd,
"ON outright ({on_fwd}) should be below TN/spot ({tn_fwd}) reflecting negative ON swap pts"
);

Ok(())
}

#[test]
fn test_notify_observers_prune_and_notify() -> Result<()> {
// Create one dead observer (dropped before notification) and one live observer.
Expand Down
19 changes: 19 additions & 0 deletions src/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,25 @@ pub fn sample_yield_term_structure() -> YieldTermMarketData {
)
}

/// Bloomberg EUR/USD forward curve, valuation 2026-05-12 (pricing date from screenshot).
/// Spot date is 2026-05-14 ("Value 05/14/26"). Values are approximate Fwds Bid outright rates.
pub fn bloomberg_eurusd_fx_forward_helper() -> FXForwardHelper {
let valuation_date = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
FXForwardHelper::new(
valuation_date,
1.17435_f64,
vec![
FXForwardQuote { tenor: Period::ON, value: 1.174188 }, // far 05/13/26
FXForwardQuote { tenor: Period::TN, value: 1.174244 }, // far 05/14/26 = spot
FXForwardQuote { tenor: Period::SN, value: 1.174354 }, // far 05/15/26
FXForwardQuote { tenor: Period::Weeks(1), value: 1.174682 }, // far 05/21/26
FXForwardQuote { tenor: Period::Weeks(2), value: 1.175068 }, // far 05/28/26
FXForwardQuote { tenor: Period::Months(2), value: 1.177487 }, // far 07/14/26
FXForwardQuote { tenor: Period::Months(4), value: 1.180255 }, // far 09/14/26
],
)
}

pub fn sample_fx_forward_helper() -> FXForwardHelper {
let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 17).unwrap();
let spot_ref = 1.1f64;
Expand Down
118 changes: 116 additions & 2 deletions src/time/period.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub const TWO_DAYS: TimeDelta = const_unwrap!(Duration::try_days(2));
#[derive(Deserialize, Serialize, PartialEq, Clone, Copy, Debug)]
pub enum Period {
ON,
TN,
SPOT,
SN,
Days(i64),
Expand All @@ -32,8 +33,9 @@ impl Period {
) -> Result<NaiveDate> {
// TODO: Change spot as T+2 to be linked to currency.
let target_date = match self {
Period::ON => valuation_date,
_ => valuation_date + TWO_DAYS,
Period::ON => valuation_date, // base = T
Period::TN => valuation_date + ONE_DAY, // base = T+1 (tom)
_ => valuation_date + TWO_DAYS, // base = T+2 (spot)
};
let mut settlement_date = (target_date + *self)?;
if settlement_date >= calendar.end_of_month(settlement_date) {
Expand All @@ -45,6 +47,34 @@ impl Period {

Ok(settlement_date)
}

/// Returns the near-leg settlement date for FX swap periods (ON/TN/SN).
/// Returns `None` for single outright tenors (SPOT and longer).
///
/// - `ON`: near = T (valuation date)
/// - `TN`: near = T+1 (tom), first business day after valuation
/// - `SN`: near = spot date (T+2, adjusted for holidays)
pub fn near_date(
&self,
valuation_date: NaiveDate,
calendar: &dyn Calendar,
) -> Result<Option<NaiveDate>> {
match self {
Period::ON => Ok(Some(valuation_date)),
Period::TN => {
let mut d = valuation_date + ONE_DAY;
while !calendar.is_business_day(d) {
d += ONE_DAY;
}
Ok(Some(d))
}
Period::SN => {
let spot = Period::SPOT.settlement_date(valuation_date, calendar)?;
Ok(Some(spot))
}
_ => Ok(None),
}
}
}

impl Add<Period> for NaiveDate {
Expand All @@ -53,6 +83,7 @@ impl Add<Period> for NaiveDate {
fn add(self, rhs: Period) -> Self::Output {
let date = match rhs {
Period::ON => self + ONE_DAY,
Period::TN => self + ONE_DAY,
Period::SPOT => self,
Period::SN => self + ONE_DAY,
Period::Days(num) => {
Expand All @@ -79,6 +110,7 @@ impl Sub<Period> for NaiveDate {
fn sub(self, rhs: Period) -> Self::Output {
let date = match rhs {
Period::ON => self - ONE_DAY,
Period::TN => self - ONE_DAY,
Period::SPOT => self,
Period::SN => self - ONE_DAY,
Period::Days(num) => {
Expand All @@ -105,6 +137,7 @@ impl Mul<Period> for u32 {
fn mul(self, rhs: Period) -> Self::Output {
match rhs {
Period::ON => Period::ON,
Period::TN => Period::TN,
Period::SPOT => Period::SPOT,
Period::SN => Period::SN,
Period::Days(num) => Period::Days(num * self as i64),
Expand All @@ -119,7 +152,9 @@ impl Mul<Period> for u32 {
mod tests {
use super::Period;
use crate::error::Result;
use crate::time::calendars::{JointCalendar, Target, UnitedStates};
use chrono::NaiveDate;

#[test]
fn test_settlement_date_target_add() -> Result<()> {
let current_date = NaiveDate::from_ymd_opt(2023, 10, 17).unwrap();
Expand Down Expand Up @@ -191,4 +226,83 @@ mod tests {
assert_eq!(2 * Period::Weeks(1), Period::Weeks(2));
assert_eq!(2 * Period::Days(1), Period::Days(2));
}

/// Settlement dates match the Bloomberg EUR/USD forward curve screenshot.
/// Valuation date: 2026-05-12 (Tuesday); spot date: 2026-05-14 ("Value 05/14/26").
#[test]
fn test_bloomberg_eurusd_settlement_dates_20260512() -> Result<()> {
let val = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
let cal = JointCalendar::new(vec![
Box::new(Target),
Box::new(UnitedStates::default()),
]);

assert_eq!(
Period::ON.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 13).unwrap()
);
assert_eq!(
Period::TN.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()
);
assert_eq!(
Period::SPOT.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 14).unwrap()
);
assert_eq!(
Period::SN.settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 15).unwrap()
);
assert_eq!(
Period::Weeks(1).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 21).unwrap()
);
assert_eq!(
Period::Weeks(2).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 5, 28).unwrap()
);
assert_eq!(
Period::Months(2).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 7, 14).unwrap()
);
assert_eq!(
Period::Months(4).settlement_date(val, &cal)?,
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
);

Ok(())
}

/// near_date() returns the near-leg date for ON/TN/SN FX swaps,
/// cross-checked against the Bloomberg screenshot (valuation 2026-05-12).
#[test]
fn test_bloomberg_eurusd_near_dates_20260512() -> Result<()> {
let val = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
let cal = JointCalendar::new(vec![
Box::new(Target),
Box::new(UnitedStates::default()),
]);

// ON: near = T (today), far = T+1
assert_eq!(
Period::ON.near_date(val, &cal)?,
Some(NaiveDate::from_ymd_opt(2026, 5, 12).unwrap())
);
// TN: near = T+1 (tom), far = T+2 = spot
assert_eq!(
Period::TN.near_date(val, &cal)?,
Some(NaiveDate::from_ymd_opt(2026, 5, 13).unwrap())
);
// SN: near = spot (T+2), far = T+3
assert_eq!(
Period::SN.near_date(val, &cal)?,
Some(NaiveDate::from_ymd_opt(2026, 5, 14).unwrap())
);
// Single outrights have no near leg
assert_eq!(Period::SPOT.near_date(val, &cal)?, None);
assert_eq!(Period::Weeks(1).near_date(val, &cal)?, None);
assert_eq!(Period::Months(1).near_date(val, &cal)?, None);

Ok(())
}
}
Loading