From 15dc31e70fcc3e35a40c1c79867a66b54a9a1735 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 21:16:15 +0000 Subject: [PATCH] feat(fx): add TN period and Bloomberg ON/TN/SN test cases from screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Period::TN (Tom-Next) variant with correct settlement logic (base T+1 → far T+2 = spot) - Add Period::near_date() API returning the near-leg date for ON/TN/SN FX swaps - Add Bloomberg EUR/USD settlement date tests anchored to the screenshot (valuation 2026-05-12, spot 2026-05-14, far-legs match Bloomberg "Dates" column) - Add bloomberg_eurusd_fx_forward_helper() in tests/common.rs with Fwds Bid rates - Add forward helper tests: settlement dates, exact match, interpolation, ON-below-TN Co-authored-by: Jeremy Wang --- src/markets/forex/quotes/forwardpoints.rs | 129 +++++++++++++++++++++- src/tests/common.rs | 19 ++++ src/time/period.rs | 118 +++++++++++++++++++- 3 files changed, 263 insertions(+), 3 deletions(-) diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index 34409cb..4ea2c16 100644 --- a/src/markets/forex/quotes/forwardpoints.rs +++ b/src/markets/forex/quotes/forwardpoints.rs @@ -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; @@ -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. diff --git a/src/tests/common.rs b/src/tests/common.rs index 2b52f64..4ebf372 100644 --- a/src/tests/common.rs +++ b/src/tests/common.rs @@ -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; diff --git a/src/time/period.rs b/src/time/period.rs index 31d9e19..77ffde0 100644 --- a/src/time/period.rs +++ b/src/time/period.rs @@ -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), @@ -32,8 +33,9 @@ impl Period { ) -> Result { // 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) { @@ -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> { + 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 for NaiveDate { @@ -53,6 +83,7 @@ impl Add 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) => { @@ -79,6 +110,7 @@ impl Sub 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) => { @@ -105,6 +137,7 @@ impl Mul 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), @@ -119,7 +152,9 @@ impl Mul 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(); @@ -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(()) + } }