From 97aa2cef203110dbf9796dff0ccdadb2daa8e8d1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 12:51:24 +0000 Subject: [PATCH 1/9] feat(fx): add TN (Tom-Next) period and near_date API for FX swap legs TN was missing from the Period enum despite ON and SN being present. Adds TN with settlement_date returning the spot date (T+2 for T+2 pairs), matching Bloomberg's tom-next convention where near=T+1 and far=spot. Also adds `near_date()` to Period so callers can obtain both legs of an ON/TN/SN FX swap independently for per-leg pricing, and includes ON as a pre-spot quote in the sample forward helper (negative outright pts). Co-authored-by: Jeremy Wang --- src/markets/forex/quotes/forwardpoints.rs | 43 +++++++ src/tests/common.rs | 9 ++ src/time/period.rs | 140 +++++++++++++++++++++- 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index 34409cb..e085df4 100644 --- a/src/markets/forex/quotes/forwardpoints.rs +++ b/src/markets/forex/quotes/forwardpoints.rs @@ -8,6 +8,44 @@ use std::any::Any; use std::cell::RefCell; use std::rc::{Rc, Weak}; +/// A single FX forward market quote, keyed by tenor. +/// +/// ## Bloomberg quote conventions — ON / TN / SN +/// +/// Bloomberg displays three kinds of pre-spot and at-spot tenors on its FX +/// Forward screen: +/// +/// | Tenor | Bloomberg name | Near leg | Far leg | +/// |-------|-----------------|----------|---------------------| +/// | `ON` | Overnight | T | T+1 | +/// | `TN` | Tom-Next | T+1 | T+2 (spot, T+2 pairs) | +/// | `SPOT`| Spot | — | T+2 | +/// | `SN` | Spot-Next | T+2 | T+3 | +/// +/// Bloomberg's **"Pts"** column shows the *swap forward points* for each +/// individual overnight period (not cumulative from spot). The **"Fwds"** +/// column shows the *outright forward rate* at the far-leg date. +/// +/// ## What `value` represents in `FXForwardHelper` +/// +/// `value` is always stored as **outright forward points from spot** (same +/// unit as the 1W, 1M, … quotes). Positive = above spot; negative = below. +/// +/// | Tenor | Typical `value` sign | Interpretation | +/// |-------|---------------------------|--------------------------------------| +/// | `ON` | negative (USD high rates) | outright pts at T+1 relative to spot | +/// | `TN` | ~0 (equals spot far leg) | same settlement as `SPOT` | +/// | `SPOT`| 0.0 | reference point | +/// | `SN` | positive | outright pts at T+3 relative to spot | +/// +/// ## Single outright forward vs 2-leg FX swap +/// +/// * **Single outright forward**: one cash exchange at the settlement date. +/// Price by calling `FXForwardHelper::get_forward(settlement_date, cal)`. +/// * **2-leg FX swap** (e.g. ON, TN, SN): simultaneous buy at the near leg +/// and sell at the far leg (or vice-versa). Obtain both dates via +/// `Period::near_date` (near leg) and `Period::settlement_date` (far leg), +/// then call `get_forward` for each leg separately. #[derive(Serialize, Deserialize, Clone, Copy, Debug)] pub struct FXForwardQuote { pub tenor: Period, @@ -218,6 +256,11 @@ mod tests { NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() ); + // TN far leg equals the spot date for standard T+2 pairs + assert_eq!( + Period::TN.settlement_date(valuation_date, &calendar)?, + NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() + ); assert_eq!( Period::SPOT.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() diff --git a/src/tests/common.rs b/src/tests/common.rs index 2b52f64..8571646 100644 --- a/src/tests/common.rs +++ b/src/tests/common.rs @@ -116,10 +116,19 @@ pub fn sample_yield_term_structure() -> YieldTermMarketData { pub fn sample_fx_forward_helper() -> FXForwardHelper { let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 17).unwrap(); let spot_ref = 1.1f64; + // 2023-10-17 is a Tuesday; T+1 = 2023-10-18, spot (T+2) = 2023-10-19. + // ON value = outright forward pts at T+1 relative to spot (negative = pre-spot). + // TN far leg equals the spot date for T+2 pairs, so it is omitted here to avoid + // a duplicate settlement date alongside SPOT. Use TN in pricing when you need the + // near/far leg structure for a tom-to-spot FX swap. FXForwardHelper::new( valuation_date, spot_ref, vec![ + FXForwardQuote { + tenor: Period::ON, + value: -0.03f64, // ~-0.3 pip; pre-spot outright pts at T+1 + }, FXForwardQuote { tenor: Period::SPOT, value: 0f64, diff --git a/src/time/period.rs b/src/time/period.rs index 31d9e19..a58cb00 100644 --- a/src/time/period.rs +++ b/src/time/period.rs @@ -13,9 +13,22 @@ use crate::utils::const_unwrap; pub const ONE_DAY: TimeDelta = const_unwrap!(Duration::try_days(1)); pub const TWO_DAYS: TimeDelta = const_unwrap!(Duration::try_days(2)); +/// Calendar period used for tenor arithmetic and FX settlement date calculations. +/// +/// For pre-spot FX swap tenors, the structure is: +/// - `ON` (Overnight): near leg = T, far leg = T+1 +/// - `TN` (Tom-Next): near leg = T+1, far leg = T+2 (spot for T+2 pairs) +/// - `SPOT`: settles at T+2 (T+1 for USDCAD) +/// - `SN` (Spot-Next): near leg = spot, far leg = spot+1 +/// +/// `settlement_date` returns the **far leg** date. Use `near_date` to obtain the +/// near leg for ON/TN/SN when pricing a 2-leg FX swap. #[derive(Deserialize, Serialize, PartialEq, Clone, Copy, Debug)] pub enum Period { + /// Overnight: 1-day FX swap from today (T) to T+1. ON, + /// Tom-Next: 1-day FX swap from tomorrow (T+1) to spot (T+2 for most pairs). + TN, SPOT, SN, Days(i64), @@ -25,14 +38,26 @@ pub enum Period { } impl Period { + /// Returns the **far-leg** settlement date for this tenor. + /// + /// Pre-spot swap tenors start from different base dates before applying the + /// period offset: + /// - `ON`: base = T → far leg = T+1 + /// - `TN`: base = T+1 → far leg = T+2 (equals spot for standard T+2 pairs) + /// - `SN` and all standard tenors: base = T+2 (spot) + /// + /// The result is rolled forward to the next business day (following convention) + /// and capped at end-of-month when the unadjusted date falls on or after it. + /// + /// TODO: Change spot as T+2 to be linked to currency (e.g. USDCAD settles T+1). pub fn settlement_date( &self, valuation_date: NaiveDate, calendar: &dyn Calendar, ) -> Result { - // TODO: Change spot as T+2 to be linked to currency. let target_date = match self { Period::ON => valuation_date, + Period::TN => valuation_date + ONE_DAY, _ => valuation_date + TWO_DAYS, }; let mut settlement_date = (target_date + *self)?; @@ -45,6 +70,41 @@ impl Period { Ok(settlement_date) } + + /// Returns the **near-leg** settlement date for FX swap tenors (ON, TN, SN). + /// + /// Bloomberg quotes ON/TN/SN as 2-leg FX swaps. This method exposes the near + /// leg so callers can price each leg independently: + /// + /// | Tenor | Near leg | Far leg (`settlement_date`) | + /// |-------|----------|-----------------------------| + /// | ON | T | T+1 | + /// | TN | T+1 | T+2 (spot for T+2 pairs) | + /// | SN | T+2 | T+3 | + /// + /// For standard forward tenors (1W, 1M, …) the near leg is implicitly the + /// spot date; returns `None` for those. + pub fn near_date( + &self, + valuation_date: NaiveDate, + calendar: &dyn Calendar, + ) -> Result> { + let raw = match self { + Period::ON => Some(valuation_date), + Period::TN => Some(valuation_date + ONE_DAY), + Period::SN => Some(valuation_date + TWO_DAYS), + _ => None, + }; + match raw { + None => Ok(None), + Some(mut d) => { + while !calendar.is_business_day(d) { + d += ONE_DAY; + } + Ok(Some(d)) + } + } + } } impl Add for NaiveDate { @@ -53,6 +113,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 +140,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 +167,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), @@ -128,6 +191,10 @@ mod tests { (current_date + Period::ON)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() ); + assert_eq!( + (current_date + Period::TN)?, + NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() + ); assert_eq!( (current_date + Period::SN)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() @@ -160,6 +227,10 @@ mod tests { (current_date - Period::ON)?, NaiveDate::from_ymd_opt(2023, 10, 16).unwrap() ); + assert_eq!( + (current_date - Period::TN)?, + NaiveDate::from_ymd_opt(2023, 10, 16).unwrap() + ); assert_eq!( (current_date - Period::SN)?, NaiveDate::from_ymd_opt(2023, 10, 16).unwrap() @@ -191,4 +262,71 @@ mod tests { assert_eq!(2 * Period::Weeks(1), Period::Weeks(2)); assert_eq!(2 * Period::Days(1), Period::Days(2)); } + + /// Verify TN (Tom-Next) settlement dates for GBPUSD (joint US+UK calendar). + /// + /// For a standard T+2 pair on 2023-10-16 (Monday): + /// ON far leg = T+1 = 2023-10-17 + /// TN far leg = T+2 = 2023-10-18 (equals SPOT) + /// SN far leg = T+3 = 2023-10-19 + #[test] + fn test_tn_settlement_date_gbpusd() -> Result<()> { + use crate::time::calendars::{JointCalendar, UnitedKingdom, UnitedStates}; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + let calendar = JointCalendar::new(vec![ + Box::new(UnitedStates::default()), + Box::new(UnitedKingdom::default()), + ]); + + // TN far leg = spot date for T+2 pairs + let tn_settle = Period::TN.settlement_date(valuation_date, &calendar)?; + let spot_settle = Period::SPOT.settlement_date(valuation_date, &calendar)?; + assert_eq!(tn_settle, spot_settle); + assert_eq!(tn_settle, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()); + + // ON far leg = T+1 + assert_eq!( + Period::ON.settlement_date(valuation_date, &calendar)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + + Ok(()) + } + + /// Verify near_date returns the correct near-leg date for ON/TN/SN swaps. + #[test] + fn test_near_date_gbpusd() -> Result<()> { + use crate::time::calendars::{JointCalendar, UnitedKingdom, UnitedStates}; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + let calendar = JointCalendar::new(vec![ + Box::new(UnitedStates::default()), + Box::new(UnitedKingdom::default()), + ]); + + // ON: near = today = 2023-10-16 + assert_eq!( + Period::ON.near_date(valuation_date, &calendar)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 16).unwrap()) + ); + + // TN: near = tom = 2023-10-17 + assert_eq!( + Period::TN.near_date(valuation_date, &calendar)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) + ); + + // SN: near = spot = 2023-10-18 + assert_eq!( + Period::SN.near_date(valuation_date, &calendar)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()) + ); + + // Standard forward tenors have no near-leg (near = spot implicitly) + assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar)?, None); + assert_eq!(Period::Months(1).near_date(valuation_date, &calendar)?, None); + + Ok(()) + } } From d3c6d80de8f8a205134a2945bf06844f93ff4d0d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 13:50:55 +0000 Subject: [PATCH 2/9] feat(fx): add SpotLag type and T+1 pair support (USDCAD) Introduces a `SpotLag` type alias and threads it through `Period::settlement_date` and `Period::near_date` so that T+1 pairs (USDCAD, USDTRY, USDRUB, etc.) are handled correctly alongside the standard T+2 convention. Key changes: - `SpotLag = i64` type alias with per-pair convention table - `settlement_date` and `near_date` now accept an explicit `spot_lag` parameter (1 for T+1 pairs, 2 for standard T+2 pairs) - `FXForwardHelper` gains a `spot_lag` field and a `with_spot_lag` constructor; the existing `new` constructor defaults to `spot_lag = 2` - Adds `test_settlement_date_usdcad` and `test_near_date_usdcad` tests covering the full ON/TN/SN/SPOT/1W settlement structure for T+1 pairs - Documents the intra-day cut-off time convention (USDCAD: noon New York) and the caller's responsibility for advancing `valuation_date` after cut-off Co-authored-by: Jeremy Wang --- src/markets/forex/quotes/forwardpoints.rs | 93 ++++++----- src/time/period.rs | 180 ++++++++++++++++++---- 2 files changed, 208 insertions(+), 65 deletions(-) diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index e085df4..5442fc1 100644 --- a/src/markets/forex/quotes/forwardpoints.rs +++ b/src/markets/forex/quotes/forwardpoints.rs @@ -1,7 +1,7 @@ use crate::error::Result; use crate::patterns::observer::{Observable, Observer}; use crate::time::calendars::Calendar; -use crate::time::period::Period; +use crate::time::period::{Period, SpotLag}; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use std::any::Any; @@ -56,16 +56,33 @@ pub struct FXForwardQuote { pub struct FXForwardHelper { pub valuation_date: NaiveDate, pub spot_ref: f64, + /// Spot settlement lag in business days (1 for USDCAD etc., 2 for standard pairs). + pub spot_lag: SpotLag, pub quotes: Vec, #[serde(skip_serializing)] observers: RefCell>>>, } impl FXForwardHelper { + /// Construct a helper for a standard T+2 currency pair. pub fn new(valuation_date: NaiveDate, spot_ref: f64, quotes: Vec) -> Self { + Self::with_spot_lag(valuation_date, spot_ref, 2, quotes) + } + + /// Construct a helper specifying the spot lag explicitly. + /// + /// Use `spot_lag = 1` for T+1 pairs (USDCAD, USDTRY, etc.) and `spot_lag = 2` + /// (the default via [`Self::new`]) for all standard T+2 pairs. + pub fn with_spot_lag( + valuation_date: NaiveDate, + spot_ref: f64, + spot_lag: SpotLag, + quotes: Vec, + ) -> Self { Self { valuation_date, spot_ref, + spot_lag, quotes, observers: RefCell::new(Vec::new()), } @@ -87,7 +104,7 @@ impl FXForwardHelper { .iter() .map(|q| { Ok(( - q.tenor.settlement_date(self.valuation_date, calendar)?, + q.tenor.settlement_date(self.valuation_date, calendar, self.spot_lag)?, q.value, )) }) @@ -176,67 +193,67 @@ mod tests { let valuation_date = NaiveDate::from_ymd_opt(2023, 3, 29).unwrap(); let calendar = Target; assert_eq!( - Period::SPOT.settlement_date(valuation_date, &calendar)?, + Period::SPOT.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 3, 31).unwrap() ); assert_eq!( - Period::SN.settlement_date(valuation_date, &calendar)?, + Period::SN.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 4, 3).unwrap() ); assert_eq!( - Period::Weeks(1).settlement_date(valuation_date, &calendar)?, + Period::Weeks(1).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 4, 11).unwrap() ); assert_eq!( - Period::Weeks(2).settlement_date(valuation_date, &calendar)?, + Period::Weeks(2).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 4, 14).unwrap() ); assert_eq!( - Period::Weeks(3).settlement_date(valuation_date, &calendar)?, + Period::Weeks(3).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 4, 21).unwrap() ); assert_eq!( - Period::Months(1).settlement_date(valuation_date, &calendar)?, + Period::Months(1).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 4, 28).unwrap() ); assert_eq!( - Period::Months(2).settlement_date(valuation_date, &calendar)?, + Period::Months(2).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 5, 31).unwrap() ); assert_eq!( - Period::Months(3).settlement_date(valuation_date, &calendar)?, + Period::Months(3).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 6, 30).unwrap() ); assert_eq!( - Period::Months(4).settlement_date(valuation_date, &calendar)?, + Period::Months(4).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 7, 31).unwrap() ); assert_eq!( - Period::Months(5).settlement_date(valuation_date, &calendar)?, + Period::Months(5).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 8, 31).unwrap() ); assert_eq!( - Period::Months(6).settlement_date(valuation_date, &calendar)?, + Period::Months(6).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 9, 29).unwrap() ); assert_eq!( - Period::Months(9).settlement_date(valuation_date, &calendar)?, + Period::Months(9).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 12, 29).unwrap() ); assert_eq!( - Period::Years(1).settlement_date(valuation_date, &calendar)?, + Period::Years(1).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 3, 28).unwrap() ); assert_eq!( - Period::Months(15).settlement_date(valuation_date, &calendar)?, + Period::Months(15).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap() ); assert_eq!( - Period::Months(18).settlement_date(valuation_date, &calendar)?, + Period::Months(18).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 9, 30).unwrap() ); assert_eq!( - Period::Years(2).settlement_date(valuation_date, &calendar)?, + Period::Years(2).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2025, 3, 31).unwrap() ); @@ -252,77 +269,77 @@ mod tests { ]); assert_eq!( - Period::ON.settlement_date(valuation_date, &calendar)?, + Period::ON.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() ); // TN far leg equals the spot date for standard T+2 pairs assert_eq!( - Period::TN.settlement_date(valuation_date, &calendar)?, + Period::TN.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() ); assert_eq!( - Period::SPOT.settlement_date(valuation_date, &calendar)?, + Period::SPOT.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() ); assert_eq!( - Period::SN.settlement_date(valuation_date, &calendar)?, + Period::SN.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 10, 19).unwrap() ); assert_eq!( - Period::Weeks(1).settlement_date(valuation_date, &calendar)?, + Period::Weeks(1).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 10, 25).unwrap() ); assert_eq!( - Period::Weeks(2).settlement_date(valuation_date, &calendar)?, + Period::Weeks(2).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 11, 1).unwrap() ); assert_eq!( - Period::Weeks(3).settlement_date(valuation_date, &calendar)?, + Period::Weeks(3).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 11, 8).unwrap() ); assert_eq!( - Period::Months(1).settlement_date(valuation_date, &calendar)?, + Period::Months(1).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 11, 20).unwrap() ); assert_eq!( - Period::Months(2).settlement_date(valuation_date, &calendar)?, + Period::Months(2).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 12, 18).unwrap() ); assert_eq!( - Period::Months(3).settlement_date(valuation_date, &calendar)?, + Period::Months(3).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 1, 18).unwrap() ); assert_eq!( - Period::Months(4).settlement_date(valuation_date, &calendar)?, + Period::Months(4).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 2, 20).unwrap() ); assert_eq!( - Period::Months(5).settlement_date(valuation_date, &calendar)?, + Period::Months(5).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 3, 18).unwrap() ); assert_eq!( - Period::Months(6).settlement_date(valuation_date, &calendar)?, + Period::Months(6).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 4, 18).unwrap() ); assert_eq!( - Period::Months(9).settlement_date(valuation_date, &calendar)?, + Period::Months(9).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 7, 18).unwrap() ); assert_eq!( - Period::Years(1).settlement_date(valuation_date, &calendar)?, + Period::Years(1).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2024, 10, 18).unwrap() ); assert_eq!( - Period::Months(15).settlement_date(valuation_date, &calendar)?, + Period::Months(15).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2025, 1, 21).unwrap() ); assert_eq!( - Period::Months(18).settlement_date(valuation_date, &calendar)?, + Period::Months(18).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2025, 4, 22).unwrap() ); assert_eq!( - Period::Years(2).settlement_date(valuation_date, &calendar)?, + Period::Years(2).settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2025, 10, 20).unwrap() ); @@ -396,7 +413,7 @@ mod tests { let q = fx_forward_helper.quotes[0]; let exact_date = q .tenor - .settlement_date(fx_forward_helper.valuation_date, &calendar)?; + .settlement_date(fx_forward_helper.valuation_date, &calendar, 2)?; let got = fx_forward_helper .get_forward(exact_date, &calendar)? .unwrap(); @@ -426,7 +443,7 @@ mod tests { let calendar = Target; // Choose a date strictly after valuation_date but before 1W settlement date - let first_settle = Period::Weeks(1).settlement_date(valuation_date, &calendar)?; + let first_settle = Period::Weeks(1).settlement_date(valuation_date, &calendar, 2)?; let target_date = valuation_date + Duration::days(1); assert!(target_date > valuation_date && target_date < first_settle); diff --git a/src/time/period.rs b/src/time/period.rs index a58cb00..3a1ebb6 100644 --- a/src/time/period.rs +++ b/src/time/period.rs @@ -13,16 +13,36 @@ use crate::utils::const_unwrap; pub const ONE_DAY: TimeDelta = const_unwrap!(Duration::try_days(1)); pub const TWO_DAYS: TimeDelta = const_unwrap!(Duration::try_days(2)); +/// Spot settlement lag in business days for a currency pair. +/// +/// Most pairs (EURUSD, GBPUSD, etc.) settle at T+2. A small set of pairs +/// settle at T+1 due to geographic proximity or market convention: +/// +/// | Lag | Pairs (examples) | +/// |-----|-------------------------------------------| +/// | 1 | USDCAD, USDTRY, USDRUB, USD/local LatAm | +/// | 2 | all other pairs (default) | +/// +/// ## Cut-off time note +/// +/// Many T+1 pairs have an intra-day cut-off (e.g. USDCAD: 12:00 noon New York; +/// USDRUB: 12:30 Moscow). Trades executed *after* the cut-off advance the +/// effective T by one business day. This library works on calendar dates only; +/// callers must pre-adjust `valuation_date` when pricing after the pair's +/// cut-off time. +pub type SpotLag = i64; + /// Calendar period used for tenor arithmetic and FX settlement date calculations. /// -/// For pre-spot FX swap tenors, the structure is: -/// - `ON` (Overnight): near leg = T, far leg = T+1 -/// - `TN` (Tom-Next): near leg = T+1, far leg = T+2 (spot for T+2 pairs) -/// - `SPOT`: settles at T+2 (T+1 for USDCAD) -/// - `SN` (Spot-Next): near leg = spot, far leg = spot+1 +/// For pre-spot FX swap tenors, the structure is (using `spot_lag` = days to spot): +/// - `ON` (Overnight): near leg = T, far leg = T+1 +/// - `TN` (Tom-Next): near leg = T+1, far leg = T+`spot_lag` +/// - `SPOT`: settles at T+`spot_lag` (T+2 standard; T+1 for USDCAD) +/// - `SN` (Spot-Next): near leg = T+`spot_lag`, far leg = T+`spot_lag`+1 /// /// `settlement_date` returns the **far leg** date. Use `near_date` to obtain the /// near leg for ON/TN/SN when pricing a 2-leg FX swap. +/// See [`SpotLag`] for the spot-lag convention per currency pair. #[derive(Deserialize, Serialize, PartialEq, Clone, Copy, Debug)] pub enum Period { /// Overnight: 1-day FX swap from today (T) to T+1. @@ -40,25 +60,34 @@ pub enum Period { impl Period { /// Returns the **far-leg** settlement date for this tenor. /// + /// `spot_lag` is the number of business days from T to the spot date for the + /// currency pair (use [`SpotLag`] constants: `1` for USDCAD and similar T+1 + /// pairs, `2` for all standard T+2 pairs). + /// /// Pre-spot swap tenors start from different base dates before applying the /// period offset: - /// - `ON`: base = T → far leg = T+1 - /// - `TN`: base = T+1 → far leg = T+2 (equals spot for standard T+2 pairs) - /// - `SN` and all standard tenors: base = T+2 (spot) + /// + /// | Tenor | Base date | Far leg | + /// |-------|--------------------|-------------------------------| + /// | `ON` | T | T+1 | + /// | `TN` | T+1 | T+1+1 (spot for T+2 pairs) | + /// | `SN` | T+`spot_lag` | spot+1 | + /// | rest | T+`spot_lag` | spot + tenor | /// /// The result is rolled forward to the next business day (following convention) /// and capped at end-of-month when the unadjusted date falls on or after it. - /// - /// TODO: Change spot as T+2 to be linked to currency (e.g. USDCAD settles T+1). pub fn settlement_date( &self, valuation_date: NaiveDate, calendar: &dyn Calendar, + spot_lag: SpotLag, ) -> Result { + let spot_offset = Duration::try_days(spot_lag) + .ok_or_else(|| Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")))?; let target_date = match self { Period::ON => valuation_date, Period::TN => valuation_date + ONE_DAY, - _ => valuation_date + TWO_DAYS, + _ => valuation_date + spot_offset, }; let mut settlement_date = (target_date + *self)?; if settlement_date >= calendar.end_of_month(settlement_date) { @@ -73,14 +102,19 @@ impl Period { /// Returns the **near-leg** settlement date for FX swap tenors (ON, TN, SN). /// + /// `spot_lag` is the number of business days from T to spot for the currency + /// pair (`1` for USDCAD etc., `2` for standard pairs — see [`SpotLag`]). + /// /// Bloomberg quotes ON/TN/SN as 2-leg FX swaps. This method exposes the near /// leg so callers can price each leg independently: /// - /// | Tenor | Near leg | Far leg (`settlement_date`) | - /// |-------|----------|-----------------------------| - /// | ON | T | T+1 | - /// | TN | T+1 | T+2 (spot for T+2 pairs) | - /// | SN | T+2 | T+3 | + /// | Tenor | Near leg | Far leg (`settlement_date`) | + /// |-------|-----------------|-------------------------------------| + /// | ON | T | T+1 | + /// | TN | T+1 | T+`spot_lag` (spot for T+2 pairs) | + /// | SN | T+`spot_lag` | T+`spot_lag`+1 | + /// + /// For USDCAD (spot_lag=1): ON near=T, far=T+1=spot; TN near=T+1=spot, far=T+2. /// /// For standard forward tenors (1W, 1M, …) the near leg is implicitly the /// spot date; returns `None` for those. @@ -88,11 +122,14 @@ impl Period { &self, valuation_date: NaiveDate, calendar: &dyn Calendar, + spot_lag: SpotLag, ) -> Result> { + let spot_offset = Duration::try_days(spot_lag) + .ok_or_else(|| Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")))?; let raw = match self { Period::ON => Some(valuation_date), Period::TN => Some(valuation_date + ONE_DAY), - Period::SN => Some(valuation_date + TWO_DAYS), + Period::SN => Some(valuation_date + spot_offset), _ => None, }; match raw { @@ -263,7 +300,7 @@ mod tests { assert_eq!(2 * Period::Days(1), Period::Days(2)); } - /// Verify TN (Tom-Next) settlement dates for GBPUSD (joint US+UK calendar). + /// Verify TN (Tom-Next) settlement dates for GBPUSD (joint US+UK calendar, T+2 spot). /// /// For a standard T+2 pair on 2023-10-16 (Monday): /// ON far leg = T+1 = 2023-10-17 @@ -280,21 +317,21 @@ mod tests { ]); // TN far leg = spot date for T+2 pairs - let tn_settle = Period::TN.settlement_date(valuation_date, &calendar)?; - let spot_settle = Period::SPOT.settlement_date(valuation_date, &calendar)?; + let tn_settle = Period::TN.settlement_date(valuation_date, &calendar, 2)?; + let spot_settle = Period::SPOT.settlement_date(valuation_date, &calendar, 2)?; assert_eq!(tn_settle, spot_settle); assert_eq!(tn_settle, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()); // ON far leg = T+1 assert_eq!( - Period::ON.settlement_date(valuation_date, &calendar)?, + Period::ON.settlement_date(valuation_date, &calendar, 2)?, NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() ); Ok(()) } - /// Verify near_date returns the correct near-leg date for ON/TN/SN swaps. + /// Verify near_date returns the correct near-leg date for ON/TN/SN swaps (T+2 pair). #[test] fn test_near_date_gbpusd() -> Result<()> { use crate::time::calendars::{JointCalendar, UnitedKingdom, UnitedStates}; @@ -307,25 +344,114 @@ mod tests { // ON: near = today = 2023-10-16 assert_eq!( - Period::ON.near_date(valuation_date, &calendar)?, + Period::ON.near_date(valuation_date, &calendar, 2)?, Some(NaiveDate::from_ymd_opt(2023, 10, 16).unwrap()) ); // TN: near = tom = 2023-10-17 assert_eq!( - Period::TN.near_date(valuation_date, &calendar)?, + Period::TN.near_date(valuation_date, &calendar, 2)?, Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) ); // SN: near = spot = 2023-10-18 assert_eq!( - Period::SN.near_date(valuation_date, &calendar)?, + Period::SN.near_date(valuation_date, &calendar, 2)?, Some(NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()) ); // Standard forward tenors have no near-leg (near = spot implicitly) - assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar)?, None); - assert_eq!(Period::Months(1).near_date(valuation_date, &calendar)?, None); + assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar, 2)?, None); + assert_eq!(Period::Months(1).near_date(valuation_date, &calendar, 2)?, None); + + Ok(()) + } + + /// Verify settlement dates for USDCAD (T+1 spot, joint US+Canada calendar). + /// + /// For a T+1 pair on 2023-10-16 (Monday): + /// ON near = T = 2023-10-16, far = T+1 = 2023-10-17 (= spot for USDCAD) + /// TN near = T+1 = 2023-10-17 (= spot), far = T+2 = 2023-10-18 (post-spot) + /// SN near = spot = 2023-10-17, far = 2023-10-18 + /// SPOT settles at T+1 = 2023-10-17 + /// + /// Cut-off: USDCAD has a 12:00 noon New York cut-off. After that time, + /// callers should advance valuation_date by one business day before calling + /// settlement_date / near_date. + #[test] + fn test_settlement_date_usdcad() -> Result<()> { + use crate::time::calendars::{Canada, JointCalendar, UnitedStates}; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + let calendar = JointCalendar::new(vec![ + Box::new(UnitedStates::default()), + Box::new(Canada::default()), + ]); + + // SPOT = T+1 + assert_eq!( + Period::SPOT.settlement_date(valuation_date, &calendar, 1)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + + // ON far leg = T+1 = spot for USDCAD + assert_eq!( + Period::ON.settlement_date(valuation_date, &calendar, 1)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + + // TN far leg = T+2 (post-spot for T+1 pairs) + assert_eq!( + Period::TN.settlement_date(valuation_date, &calendar, 1)?, + NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() + ); + + // SN far leg = T+2 + assert_eq!( + Period::SN.settlement_date(valuation_date, &calendar, 1)?, + NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() + ); + + // 1W forward from spot (T+1) + assert_eq!( + Period::Weeks(1).settlement_date(valuation_date, &calendar, 1)?, + NaiveDate::from_ymd_opt(2023, 10, 24).unwrap() + ); + + Ok(()) + } + + /// Verify near_date for USDCAD (T+1 spot). + #[test] + fn test_near_date_usdcad() -> Result<()> { + use crate::time::calendars::{Canada, JointCalendar, UnitedStates}; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + let calendar = JointCalendar::new(vec![ + Box::new(UnitedStates::default()), + Box::new(Canada::default()), + ]); + + // ON: near = T = 2023-10-16 + assert_eq!( + Period::ON.near_date(valuation_date, &calendar, 1)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 16).unwrap()) + ); + + // TN: near = T+1 = spot = 2023-10-17 + assert_eq!( + Period::TN.near_date(valuation_date, &calendar, 1)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) + ); + + // SN: near = spot = T+1 = 2023-10-17 + assert_eq!( + Period::SN.near_date(valuation_date, &calendar, 1)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) + ); + + // Standard forward tenors have no near-leg + assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar, 1)?, None); Ok(()) } From ded663c251d23014a338bd72c5564c2eff8c7308 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 14:06:10 +0000 Subject: [PATCH 3/9] refactor(fx): move spot-lag convention onto FXUnderlying, simplify Period API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Period::settlement_date and near_date no longer take a spot_lag argument — they default to the standard T+2 convention. Pair-specific logic (T+1 for USDCAD etc.) lives on FXUnderlying, which already owns settles(). The lag-aware internals are pub(crate) so FXForwardHelper can still use them without exposing the parameter in the public API. New on FXUnderlying: - settlement_date / near_date — use the pair's settles() automatically - cutoff_utc() — pair-specific UTC cut-off (USDCAD: 17:00) - effective_valuation_date() — advances past the cut-off when market data arrives after it - forward_helper() — builds FXForwardHelper with the right lag Co-authored-by: Jeremy Wang --- src/derivatives/forex/basic.rs | 168 +++++++++++++++- src/markets/forex/quotes/forwardpoints.rs | 89 +++++---- src/time/period.rs | 225 +++++++--------------- 3 files changed, 275 insertions(+), 207 deletions(-) diff --git a/src/derivatives/forex/basic.rs b/src/derivatives/forex/basic.rs index 82bfc06..3011e30 100644 --- a/src/derivatives/forex/basic.rs +++ b/src/derivatives/forex/basic.rs @@ -1,12 +1,14 @@ use crate::error::Result; use crate::markets::forex::market_context::FxMarketContext; +use crate::markets::forex::quotes::forwardpoints::{FXForwardHelper, FXForwardQuote}; use crate::time::calendars::{ Calendar, Canada, Japan, JointCalendar, Target, UnitedKingdom, UnitedStates, }; use crate::time::daycounters::DayCounters; use crate::time::daycounters::actual360::Actual360; use crate::time::daycounters::actual365fixed::Actual365Fixed; -use chrono::NaiveTime; +use crate::time::period::Period; +use chrono::{Duration, NaiveDate, NaiveTime}; use iso_currency::Currency; use serde::{Deserialize, Serialize}; use std::string::ToString; @@ -61,8 +63,72 @@ impl FXUnderlying { } } - pub fn hours(&self) -> NaiveTime { - NaiveTime::from_hms_micro_opt(22, 0, 0, 0).unwrap() + /// UTC cut-off time after which the effective valuation date advances to the + /// next business day. Varies by pair: USDCAD cuts at noon New York (17:00 UTC); + /// most other pairs cut at London close (22:00 UTC). + pub fn cutoff_utc(&self) -> NaiveTime { + match self { + FXUnderlying::USDCAD => NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + _ => NaiveTime::from_hms_opt(22, 0, 0).unwrap(), + } + } + + /// Effective valuation date after applying the pair's cut-off time. + /// + /// If `market_data_time` (UTC) is at or after `cutoff_utc`, the effective + /// valuation date advances to the next business day on the pair's calendar. + /// Pass this date to `settlement_date` / `near_date` / `forward_helper`. + pub fn effective_valuation_date( + &self, + valuation_date: NaiveDate, + market_data_time: NaiveTime, + ) -> NaiveDate { + if market_data_time >= self.cutoff_utc() { + let cal = self.calendar(); + let mut next = valuation_date + Duration::days(1); + while !cal.is_business_day(next) { + next += Duration::days(1); + } + next + } else { + valuation_date + } + } + + /// Far-leg settlement date for this pair's spot convention. + /// + /// Uses the pair's `settles()` lag and `calendar()` so callers do not need to + /// supply those separately. For standard T+2 pairs this is equivalent to + /// `Period::settlement_date`; for T+1 pairs (e.g. USDCAD) the spot base is T+1. + pub fn settlement_date(&self, period: Period, valuation_date: NaiveDate) -> Result { + let cal = self.calendar(); + period.settlement_date_with_lag(valuation_date, &cal, self.settles() as i64) + } + + /// Near-leg date for ON/TN/SN swaps using this pair's spot convention. + /// + /// Returns `None` for standard forward tenors (1W, 1M, …) where the near leg + /// is implicitly the spot date. + pub fn near_date( + &self, + period: Period, + valuation_date: NaiveDate, + ) -> Result> { + let cal = self.calendar(); + period.near_date_with_lag(valuation_date, &cal, self.settles() as i64) + } + + /// Build an [`FXForwardHelper`] using this pair's spot-lag convention. + /// + /// Prefer this over constructing the helper directly so the correct spot lag + /// is applied without the caller needing to know it. + pub fn forward_helper( + &self, + valuation_date: NaiveDate, + spot_ref: f64, + quotes: Vec, + ) -> FXForwardHelper { + FXForwardHelper::with_spot_lag(valuation_date, spot_ref, self.settles() as i64, quotes) } pub fn dom_currency(&self) -> Currency { @@ -133,6 +199,93 @@ mod tests { assert_eq!(FXUnderlying::EURUSD.settles(), 2); } + /// USDCAD (T+1) settlement dates via FXUnderlying — the pair's spot convention + /// is applied automatically without the caller supplying a spot lag. + #[test] + fn test_settlement_date_usdcad() -> Result<()> { + use crate::time::period::Period; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + + assert_eq!( + FXUnderlying::USDCAD.settlement_date(Period::SPOT, valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + assert_eq!( + FXUnderlying::USDCAD.settlement_date(Period::ON, valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + assert_eq!( + FXUnderlying::USDCAD.settlement_date(Period::TN, valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() + ); + assert_eq!( + FXUnderlying::USDCAD.settlement_date(Period::SN, valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() + ); + assert_eq!( + FXUnderlying::USDCAD.settlement_date(Period::Weeks(1), valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 24).unwrap() + ); + + Ok(()) + } + + /// USDCAD near-leg dates for ON/TN/SN swaps via FXUnderlying. + #[test] + fn test_near_date_usdcad() -> Result<()> { + use crate::time::period::Period; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + + assert_eq!( + FXUnderlying::USDCAD.near_date(Period::ON, valuation_date)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 16).unwrap()) + ); + assert_eq!( + FXUnderlying::USDCAD.near_date(Period::TN, valuation_date)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) + ); + assert_eq!( + FXUnderlying::USDCAD.near_date(Period::SN, valuation_date)?, + Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) + ); + assert_eq!(FXUnderlying::USDCAD.near_date(Period::Weeks(1), valuation_date)?, None); + + Ok(()) + } + + /// effective_valuation_date advances past the cut-off and stays put before it. + #[test] + fn test_effective_valuation_date() { + let monday = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + + // Before USDCAD cut-off (noon NY = 17:00 UTC) → same day + let before = NaiveTime::from_hms_opt(16, 59, 0).unwrap(); + assert_eq!(FXUnderlying::USDCAD.effective_valuation_date(monday, before), monday); + + // At/after USDCAD cut-off → next business day (Tuesday) + let after = NaiveTime::from_hms_opt(17, 0, 0).unwrap(); + assert_eq!( + FXUnderlying::USDCAD.effective_valuation_date(monday, after), + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + + // EURUSD cuts at 22:00 UTC; data at 21:59 → same day + let before_eurusd = NaiveTime::from_hms_opt(21, 59, 0).unwrap(); + assert_eq!( + FXUnderlying::EURUSD.effective_valuation_date(monday, before_eurusd), + monday + ); + + // At 22:00 → next business day + let after_eurusd = NaiveTime::from_hms_opt(22, 0, 0).unwrap(); + assert_eq!( + FXUnderlying::EURUSD.effective_valuation_date(monday, after_eurusd), + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + } + #[test] fn test_other_static() -> Result<()> { let d1 = NaiveDate::from_ymd_opt(2023, 11, 24).unwrap(); @@ -141,9 +294,14 @@ mod tests { FXUnderlying::EURUSD.day_count().day_count(d1, d2)?, Actual360.day_count(d1, d2)? ); + // Standard pairs cut at London close (22:00 UTC); USDCAD cuts at noon NY (17:00 UTC) + assert_eq!( + FXUnderlying::EURUSD.cutoff_utc(), + NaiveTime::from_hms_opt(22, 0, 0).unwrap() + ); assert_eq!( - FXUnderlying::EURUSD.hours(), - NaiveTime::from_hms_micro_opt(22, 0, 0, 0).unwrap() + FXUnderlying::USDCAD.cutoff_utc(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap() ); Ok(()) diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index 5442fc1..0779002 100644 --- a/src/markets/forex/quotes/forwardpoints.rs +++ b/src/markets/forex/quotes/forwardpoints.rs @@ -1,7 +1,7 @@ use crate::error::Result; use crate::patterns::observer::{Observable, Observer}; use crate::time::calendars::Calendar; -use crate::time::period::{Period, SpotLag}; +use crate::time::period::Period; use chrono::NaiveDate; use serde::{Deserialize, Serialize}; use std::any::Any; @@ -56,8 +56,7 @@ pub struct FXForwardQuote { pub struct FXForwardHelper { pub valuation_date: NaiveDate, pub spot_ref: f64, - /// Spot settlement lag in business days (1 for USDCAD etc., 2 for standard pairs). - pub spot_lag: SpotLag, + spot_lag: i64, pub quotes: Vec, #[serde(skip_serializing)] observers: RefCell>>>, @@ -69,14 +68,14 @@ impl FXForwardHelper { Self::with_spot_lag(valuation_date, spot_ref, 2, quotes) } - /// Construct a helper specifying the spot lag explicitly. + /// Construct a helper with an explicit spot lag. /// - /// Use `spot_lag = 1` for T+1 pairs (USDCAD, USDTRY, etc.) and `spot_lag = 2` - /// (the default via [`Self::new`]) for all standard T+2 pairs. - pub fn with_spot_lag( + /// Prefer building via `FXUnderlying::forward_helper` so the lag is derived + /// automatically from the pair's convention rather than supplied ad-hoc. + pub(crate) fn with_spot_lag( valuation_date: NaiveDate, spot_ref: f64, - spot_lag: SpotLag, + spot_lag: i64, quotes: Vec, ) -> Self { Self { @@ -104,7 +103,7 @@ impl FXForwardHelper { .iter() .map(|q| { Ok(( - q.tenor.settlement_date(self.valuation_date, calendar, self.spot_lag)?, + q.tenor.settlement_date_with_lag(self.valuation_date, calendar, self.spot_lag)?, q.value, )) }) @@ -193,67 +192,67 @@ mod tests { let valuation_date = NaiveDate::from_ymd_opt(2023, 3, 29).unwrap(); let calendar = Target; assert_eq!( - Period::SPOT.settlement_date(valuation_date, &calendar, 2)?, + Period::SPOT.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 3, 31).unwrap() ); assert_eq!( - Period::SN.settlement_date(valuation_date, &calendar, 2)?, + Period::SN.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 4, 3).unwrap() ); assert_eq!( - Period::Weeks(1).settlement_date(valuation_date, &calendar, 2)?, + Period::Weeks(1).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 4, 11).unwrap() ); assert_eq!( - Period::Weeks(2).settlement_date(valuation_date, &calendar, 2)?, + Period::Weeks(2).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 4, 14).unwrap() ); assert_eq!( - Period::Weeks(3).settlement_date(valuation_date, &calendar, 2)?, + Period::Weeks(3).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 4, 21).unwrap() ); assert_eq!( - Period::Months(1).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(1).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 4, 28).unwrap() ); assert_eq!( - Period::Months(2).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(2).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 5, 31).unwrap() ); assert_eq!( - Period::Months(3).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(3).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 6, 30).unwrap() ); assert_eq!( - Period::Months(4).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(4).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 7, 31).unwrap() ); assert_eq!( - Period::Months(5).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(5).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 8, 31).unwrap() ); assert_eq!( - Period::Months(6).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(6).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 9, 29).unwrap() ); assert_eq!( - Period::Months(9).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(9).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 12, 29).unwrap() ); assert_eq!( - Period::Years(1).settlement_date(valuation_date, &calendar, 2)?, + Period::Years(1).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 3, 28).unwrap() ); assert_eq!( - Period::Months(15).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(15).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 6, 28).unwrap() ); assert_eq!( - Period::Months(18).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(18).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 9, 30).unwrap() ); assert_eq!( - Period::Years(2).settlement_date(valuation_date, &calendar, 2)?, + Period::Years(2).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2025, 3, 31).unwrap() ); @@ -269,77 +268,77 @@ mod tests { ]); assert_eq!( - Period::ON.settlement_date(valuation_date, &calendar, 2)?, + Period::ON.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() ); // TN far leg equals the spot date for standard T+2 pairs assert_eq!( - Period::TN.settlement_date(valuation_date, &calendar, 2)?, + Period::TN.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() ); assert_eq!( - Period::SPOT.settlement_date(valuation_date, &calendar, 2)?, + Period::SPOT.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() ); assert_eq!( - Period::SN.settlement_date(valuation_date, &calendar, 2)?, + Period::SN.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 19).unwrap() ); assert_eq!( - Period::Weeks(1).settlement_date(valuation_date, &calendar, 2)?, + Period::Weeks(1).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 25).unwrap() ); assert_eq!( - Period::Weeks(2).settlement_date(valuation_date, &calendar, 2)?, + Period::Weeks(2).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 11, 1).unwrap() ); assert_eq!( - Period::Weeks(3).settlement_date(valuation_date, &calendar, 2)?, + Period::Weeks(3).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 11, 8).unwrap() ); assert_eq!( - Period::Months(1).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(1).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 11, 20).unwrap() ); assert_eq!( - Period::Months(2).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(2).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 12, 18).unwrap() ); assert_eq!( - Period::Months(3).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(3).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 1, 18).unwrap() ); assert_eq!( - Period::Months(4).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(4).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 2, 20).unwrap() ); assert_eq!( - Period::Months(5).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(5).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 3, 18).unwrap() ); assert_eq!( - Period::Months(6).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(6).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 4, 18).unwrap() ); assert_eq!( - Period::Months(9).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(9).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 7, 18).unwrap() ); assert_eq!( - Period::Years(1).settlement_date(valuation_date, &calendar, 2)?, + Period::Years(1).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2024, 10, 18).unwrap() ); assert_eq!( - Period::Months(15).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(15).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2025, 1, 21).unwrap() ); assert_eq!( - Period::Months(18).settlement_date(valuation_date, &calendar, 2)?, + Period::Months(18).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2025, 4, 22).unwrap() ); assert_eq!( - Period::Years(2).settlement_date(valuation_date, &calendar, 2)?, + Period::Years(2).settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2025, 10, 20).unwrap() ); @@ -413,7 +412,7 @@ mod tests { let q = fx_forward_helper.quotes[0]; let exact_date = q .tenor - .settlement_date(fx_forward_helper.valuation_date, &calendar, 2)?; + .settlement_date(fx_forward_helper.valuation_date, &calendar)?; let got = fx_forward_helper .get_forward(exact_date, &calendar)? .unwrap(); @@ -443,7 +442,7 @@ mod tests { let calendar = Target; // Choose a date strictly after valuation_date but before 1W settlement date - let first_settle = Period::Weeks(1).settlement_date(valuation_date, &calendar, 2)?; + let first_settle = Period::Weeks(1).settlement_date(valuation_date, &calendar)?; let target_date = valuation_date + Duration::days(1); assert!(target_date > valuation_date && target_date < first_settle); diff --git a/src/time/period.rs b/src/time/period.rs index 3a1ebb6..5ea19ab 100644 --- a/src/time/period.rs +++ b/src/time/period.rs @@ -13,36 +13,19 @@ use crate::utils::const_unwrap; pub const ONE_DAY: TimeDelta = const_unwrap!(Duration::try_days(1)); pub const TWO_DAYS: TimeDelta = const_unwrap!(Duration::try_days(2)); -/// Spot settlement lag in business days for a currency pair. -/// -/// Most pairs (EURUSD, GBPUSD, etc.) settle at T+2. A small set of pairs -/// settle at T+1 due to geographic proximity or market convention: -/// -/// | Lag | Pairs (examples) | -/// |-----|-------------------------------------------| -/// | 1 | USDCAD, USDTRY, USDRUB, USD/local LatAm | -/// | 2 | all other pairs (default) | -/// -/// ## Cut-off time note -/// -/// Many T+1 pairs have an intra-day cut-off (e.g. USDCAD: 12:00 noon New York; -/// USDRUB: 12:30 Moscow). Trades executed *after* the cut-off advance the -/// effective T by one business day. This library works on calendar dates only; -/// callers must pre-adjust `valuation_date` when pricing after the pair's -/// cut-off time. -pub type SpotLag = i64; - /// Calendar period used for tenor arithmetic and FX settlement date calculations. /// -/// For pre-spot FX swap tenors, the structure is (using `spot_lag` = days to spot): -/// - `ON` (Overnight): near leg = T, far leg = T+1 -/// - `TN` (Tom-Next): near leg = T+1, far leg = T+`spot_lag` -/// - `SPOT`: settles at T+`spot_lag` (T+2 standard; T+1 for USDCAD) -/// - `SN` (Spot-Next): near leg = T+`spot_lag`, far leg = T+`spot_lag`+1 +/// For pre-spot FX swap tenors (T+2 standard convention): +/// - `ON` (Overnight): near leg = T, far leg = T+1 +/// - `TN` (Tom-Next): near leg = T+1, far leg = T+2 (spot) +/// - `SPOT`: settles at T+2 +/// - `SN` (Spot-Next): near leg = T+2, far leg = T+3 +/// +/// `settlement_date` returns the **far leg** date and defaults to the standard T+2 +/// spot convention. For pairs with a different spot lag (e.g. USDCAD at T+1), use +/// the settlement methods on `FXUnderlying`, which apply the pair's own convention. /// -/// `settlement_date` returns the **far leg** date. Use `near_date` to obtain the -/// near leg for ON/TN/SN when pricing a 2-leg FX swap. -/// See [`SpotLag`] for the spot-lag convention per currency pair. +/// Use `near_date` to obtain the near leg for ON/TN/SN when pricing a 2-leg FX swap. #[derive(Deserialize, Serialize, PartialEq, Clone, Copy, Debug)] pub enum Period { /// Overnight: 1-day FX swap from today (T) to T+1. @@ -58,29 +41,64 @@ pub enum Period { } impl Period { - /// Returns the **far-leg** settlement date for this tenor. - /// - /// `spot_lag` is the number of business days from T to the spot date for the - /// currency pair (use [`SpotLag`] constants: `1` for USDCAD and similar T+1 - /// pairs, `2` for all standard T+2 pairs). + /// Returns the **far-leg** settlement date for this tenor using the standard T+2 + /// spot convention. /// /// Pre-spot swap tenors start from different base dates before applying the /// period offset: /// - /// | Tenor | Base date | Far leg | - /// |-------|--------------------|-------------------------------| - /// | `ON` | T | T+1 | - /// | `TN` | T+1 | T+1+1 (spot for T+2 pairs) | - /// | `SN` | T+`spot_lag` | spot+1 | - /// | rest | T+`spot_lag` | spot + tenor | + /// | Tenor | Base date | Far leg | + /// |-------|-----------|--------------| + /// | `ON` | T | T+1 | + /// | `TN` | T+1 | T+2 (spot) | + /// | `SN` | T+2 | T+3 | + /// | rest | T+2 | spot + tenor | /// /// The result is rolled forward to the next business day (following convention) /// and capped at end-of-month when the unadjusted date falls on or after it. + /// + /// For pairs with a non-standard spot lag (e.g. USDCAD at T+1), use + /// `FXUnderlying::settlement_date` instead, which applies the pair's convention. pub fn settlement_date( &self, valuation_date: NaiveDate, calendar: &dyn Calendar, - spot_lag: SpotLag, + ) -> Result { + self.settlement_date_with_lag(valuation_date, calendar, 2) + } + + /// Returns the **near-leg** settlement date for FX swap tenors (ON, TN, SN), + /// using the standard T+2 spot convention. + /// + /// Bloomberg quotes ON/TN/SN as 2-leg FX swaps. This method exposes the near + /// leg so callers can price each leg independently: + /// + /// | Tenor | Near leg | Far leg (`settlement_date`) | + /// |-------|----------|-----------------------------| + /// | ON | T | T+1 | + /// | TN | T+1 | T+2 (spot) | + /// | SN | T+2 | T+3 | + /// + /// For standard forward tenors (1W, 1M, …) the near leg is implicitly the + /// spot date; returns `None` for those. + /// + /// For pairs with a non-standard spot lag (e.g. USDCAD at T+1), use + /// `FXUnderlying::near_date` instead. + pub fn near_date( + &self, + valuation_date: NaiveDate, + calendar: &dyn Calendar, + ) -> Result> { + self.near_date_with_lag(valuation_date, calendar, 2) + } + + /// Settlement date with an explicit spot lag — used internally by `FXUnderlying` + /// and `FXForwardHelper` for pairs whose spot date differs from the T+2 default. + pub(crate) fn settlement_date_with_lag( + &self, + valuation_date: NaiveDate, + calendar: &dyn Calendar, + spot_lag: i64, ) -> Result { let spot_offset = Duration::try_days(spot_lag) .ok_or_else(|| Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")))?; @@ -96,33 +114,15 @@ impl Period { while !calendar.is_business_day(settlement_date) { settlement_date += ONE_DAY; } - Ok(settlement_date) } - /// Returns the **near-leg** settlement date for FX swap tenors (ON, TN, SN). - /// - /// `spot_lag` is the number of business days from T to spot for the currency - /// pair (`1` for USDCAD etc., `2` for standard pairs — see [`SpotLag`]). - /// - /// Bloomberg quotes ON/TN/SN as 2-leg FX swaps. This method exposes the near - /// leg so callers can price each leg independently: - /// - /// | Tenor | Near leg | Far leg (`settlement_date`) | - /// |-------|-----------------|-------------------------------------| - /// | ON | T | T+1 | - /// | TN | T+1 | T+`spot_lag` (spot for T+2 pairs) | - /// | SN | T+`spot_lag` | T+`spot_lag`+1 | - /// - /// For USDCAD (spot_lag=1): ON near=T, far=T+1=spot; TN near=T+1=spot, far=T+2. - /// - /// For standard forward tenors (1W, 1M, …) the near leg is implicitly the - /// spot date; returns `None` for those. - pub fn near_date( + /// Near-leg date with an explicit spot lag — used internally by `FXUnderlying`. + pub(crate) fn near_date_with_lag( &self, valuation_date: NaiveDate, calendar: &dyn Calendar, - spot_lag: SpotLag, + spot_lag: i64, ) -> Result> { let spot_offset = Duration::try_days(spot_lag) .ok_or_else(|| Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")))?; @@ -317,14 +317,14 @@ mod tests { ]); // TN far leg = spot date for T+2 pairs - let tn_settle = Period::TN.settlement_date(valuation_date, &calendar, 2)?; - let spot_settle = Period::SPOT.settlement_date(valuation_date, &calendar, 2)?; + let tn_settle = Period::TN.settlement_date(valuation_date, &calendar)?; + let spot_settle = Period::SPOT.settlement_date(valuation_date, &calendar)?; assert_eq!(tn_settle, spot_settle); assert_eq!(tn_settle, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()); // ON far leg = T+1 assert_eq!( - Period::ON.settlement_date(valuation_date, &calendar, 2)?, + Period::ON.settlement_date(valuation_date, &calendar)?, NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() ); @@ -344,114 +344,25 @@ mod tests { // ON: near = today = 2023-10-16 assert_eq!( - Period::ON.near_date(valuation_date, &calendar, 2)?, + Period::ON.near_date(valuation_date, &calendar)?, Some(NaiveDate::from_ymd_opt(2023, 10, 16).unwrap()) ); // TN: near = tom = 2023-10-17 assert_eq!( - Period::TN.near_date(valuation_date, &calendar, 2)?, + Period::TN.near_date(valuation_date, &calendar)?, Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) ); // SN: near = spot = 2023-10-18 assert_eq!( - Period::SN.near_date(valuation_date, &calendar, 2)?, + Period::SN.near_date(valuation_date, &calendar)?, Some(NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()) ); // Standard forward tenors have no near-leg (near = spot implicitly) - assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar, 2)?, None); - assert_eq!(Period::Months(1).near_date(valuation_date, &calendar, 2)?, None); - - Ok(()) - } - - /// Verify settlement dates for USDCAD (T+1 spot, joint US+Canada calendar). - /// - /// For a T+1 pair on 2023-10-16 (Monday): - /// ON near = T = 2023-10-16, far = T+1 = 2023-10-17 (= spot for USDCAD) - /// TN near = T+1 = 2023-10-17 (= spot), far = T+2 = 2023-10-18 (post-spot) - /// SN near = spot = 2023-10-17, far = 2023-10-18 - /// SPOT settles at T+1 = 2023-10-17 - /// - /// Cut-off: USDCAD has a 12:00 noon New York cut-off. After that time, - /// callers should advance valuation_date by one business day before calling - /// settlement_date / near_date. - #[test] - fn test_settlement_date_usdcad() -> Result<()> { - use crate::time::calendars::{Canada, JointCalendar, UnitedStates}; - - let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); - let calendar = JointCalendar::new(vec![ - Box::new(UnitedStates::default()), - Box::new(Canada::default()), - ]); - - // SPOT = T+1 - assert_eq!( - Period::SPOT.settlement_date(valuation_date, &calendar, 1)?, - NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() - ); - - // ON far leg = T+1 = spot for USDCAD - assert_eq!( - Period::ON.settlement_date(valuation_date, &calendar, 1)?, - NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() - ); - - // TN far leg = T+2 (post-spot for T+1 pairs) - assert_eq!( - Period::TN.settlement_date(valuation_date, &calendar, 1)?, - NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() - ); - - // SN far leg = T+2 - assert_eq!( - Period::SN.settlement_date(valuation_date, &calendar, 1)?, - NaiveDate::from_ymd_opt(2023, 10, 18).unwrap() - ); - - // 1W forward from spot (T+1) - assert_eq!( - Period::Weeks(1).settlement_date(valuation_date, &calendar, 1)?, - NaiveDate::from_ymd_opt(2023, 10, 24).unwrap() - ); - - Ok(()) - } - - /// Verify near_date for USDCAD (T+1 spot). - #[test] - fn test_near_date_usdcad() -> Result<()> { - use crate::time::calendars::{Canada, JointCalendar, UnitedStates}; - - let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); - let calendar = JointCalendar::new(vec![ - Box::new(UnitedStates::default()), - Box::new(Canada::default()), - ]); - - // ON: near = T = 2023-10-16 - assert_eq!( - Period::ON.near_date(valuation_date, &calendar, 1)?, - Some(NaiveDate::from_ymd_opt(2023, 10, 16).unwrap()) - ); - - // TN: near = T+1 = spot = 2023-10-17 - assert_eq!( - Period::TN.near_date(valuation_date, &calendar, 1)?, - Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) - ); - - // SN: near = spot = T+1 = 2023-10-17 - assert_eq!( - Period::SN.near_date(valuation_date, &calendar, 1)?, - Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) - ); - - // Standard forward tenors have no near-leg - assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar, 1)?, None); + assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar)?, None); + assert_eq!(Period::Months(1).near_date(valuation_date, &calendar)?, None); Ok(()) } From 0c423c4e896a475c3b068dc51d6bb93d08c22563 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 15:55:33 +0000 Subject: [PATCH 4/9] feat(fx): add more FX pairs with spot-lag and cutoff conventions Expands FXUnderlying with G10 and EM pairs, encoding each pair's T+1 vs T+2 settlement and UTC cut-off time on the enum itself so callers never need to look up conventions manually. New T+1 pairs: USDMXN (17:00 UTC), USDTRY (09:00 UTC noon Istanbul), USDRUB (09:30 UTC 12:30 Moscow). New T+2 pairs: AUDUSD, NZDUSD, USDCHF, USDNOK, USDSEK, EURCHF, EURNOK, EURSEK, EURJPY (100 fwd-pts converter). Adds currency_to_country mappings for AUD, NZD, CHF, NOK, SEK, MXN, TRY, RUB and tests for settles(), cutoff_utc(), settlement_date(), and effective_valuation_date() on new pairs. Co-authored-by: Jeremy Wang --- src/derivatives/forex/basic.rs | 142 ++++++++++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 9 deletions(-) diff --git a/src/derivatives/forex/basic.rs b/src/derivatives/forex/basic.rs index 3011e30..7b4beb6 100644 --- a/src/derivatives/forex/basic.rs +++ b/src/derivatives/forex/basic.rs @@ -2,7 +2,8 @@ use crate::error::Result; use crate::markets::forex::market_context::FxMarketContext; use crate::markets::forex::quotes::forwardpoints::{FXForwardHelper, FXForwardQuote}; use crate::time::calendars::{ - Calendar, Canada, Japan, JointCalendar, Target, UnitedKingdom, UnitedStates, + Australia, Calendar, Canada, Japan, JointCalendar, Mexico, NewZealand, Norway, Russia, Sweden, + Switzerland, Target, Turkey, UnitedKingdom, UnitedStates, }; use crate::time::daycounters::DayCounters; use crate::time::daycounters::actual360::Actual360; @@ -16,15 +17,31 @@ use strum_macros::{Display, EnumString}; #[derive(Deserialize, Serialize, Display, EnumString, Debug)] pub enum FXUnderlying { + // EUR crosses EURGBP, EURUSD, EURCAD, EURJPY, + EURCHF, + EURNOK, + EURSEK, + // GBP crosses GBPUSD, GBPCAD, GBPJPY, - USDCAD, + // USD crosses — T+2 + AUDUSD, + NZDUSD, + USDCHF, + USDNOK, + USDSEK, USDJPY, + // USD crosses — T+1 + USDCAD, + USDMXN, + USDTRY, + USDRUB, + // other crosses CADJPY, } @@ -36,15 +53,24 @@ impl FXUnderlying { Currency::USD => Box::new(UnitedStates::default()), Currency::JPY => Box::new(Japan), Currency::CAD => Box::new(Canada::default()), + Currency::AUD => Box::new(Australia::default()), + Currency::NZD => Box::new(NewZealand::default()), + Currency::CHF => Box::new(Switzerland), + Currency::NOK => Box::new(Norway), + Currency::SEK => Box::new(Sweden), + Currency::MXN => Box::new(Mexico), + Currency::TRY => Box::new(Turkey), + Currency::RUB => Box::new(Russia::default()), _ => Box::new(Target), } } pub fn forward_points_converter(&self) -> f64 { match self { - FXUnderlying::CADJPY => 100f64, - FXUnderlying::USDJPY => 100f64, - FXUnderlying::GBPJPY => 100f64, + FXUnderlying::CADJPY + | FXUnderlying::USDJPY + | FXUnderlying::GBPJPY + | FXUnderlying::EURJPY => 100f64, _ => 10000f64, } } @@ -58,17 +84,29 @@ impl FXUnderlying { pub fn settles(&self) -> i8 { match self { - FXUnderlying::USDCAD => 1, + // T+1 settlement pairs + FXUnderlying::USDCAD | FXUnderlying::USDMXN | FXUnderlying::USDTRY + | FXUnderlying::USDRUB => 1, _ => 2, } } /// UTC cut-off time after which the effective valuation date advances to the - /// next business day. Varies by pair: USDCAD cuts at noon New York (17:00 UTC); - /// most other pairs cut at London close (22:00 UTC). + /// next business day. + /// + /// | Pair | Cut-off (UTC) | Local time | + /// |------|--------------|------------| + /// | USDCAD, USDMXN | 17:00 | noon New York | + /// | USDTRY | 09:00 | noon Istanbul (UTC+3) | + /// | USDRUB | 09:30 | 12:30 Moscow (UTC+3) | + /// | All others | 22:00 | London close | pub fn cutoff_utc(&self) -> NaiveTime { match self { - FXUnderlying::USDCAD => NaiveTime::from_hms_opt(17, 0, 0).unwrap(), + FXUnderlying::USDCAD | FXUnderlying::USDMXN => { + NaiveTime::from_hms_opt(17, 0, 0).unwrap() + } + FXUnderlying::USDTRY => NaiveTime::from_hms_opt(9, 0, 0).unwrap(), + FXUnderlying::USDRUB => NaiveTime::from_hms_opt(9, 30, 0).unwrap(), _ => NaiveTime::from_hms_opt(22, 0, 0).unwrap(), } } @@ -306,4 +344,90 @@ mod tests { Ok(()) } + + #[test] + fn test_t1_pairs_settles() { + assert_eq!(FXUnderlying::USDMXN.settles(), 1); + assert_eq!(FXUnderlying::USDTRY.settles(), 1); + assert_eq!(FXUnderlying::USDRUB.settles(), 1); + // T+2 pairs unchanged + assert_eq!(FXUnderlying::AUDUSD.settles(), 2); + assert_eq!(FXUnderlying::NZDUSD.settles(), 2); + assert_eq!(FXUnderlying::USDCHF.settles(), 2); + } + + #[test] + fn test_cutoff_utc_all_pairs() { + // noon New York (UTC-5 standard / UTC-4 DST → use UTC offset of 17:00 UTC as noon NY EDT) + assert_eq!(FXUnderlying::USDMXN.cutoff_utc(), NaiveTime::from_hms_opt(17, 0, 0).unwrap()); + // noon Istanbul (UTC+3) = 09:00 UTC + assert_eq!(FXUnderlying::USDTRY.cutoff_utc(), NaiveTime::from_hms_opt(9, 0, 0).unwrap()); + // 12:30 Moscow (UTC+3) = 09:30 UTC + assert_eq!(FXUnderlying::USDRUB.cutoff_utc(), NaiveTime::from_hms_opt(9, 30, 0).unwrap()); + // standard London close + assert_eq!(FXUnderlying::AUDUSD.cutoff_utc(), NaiveTime::from_hms_opt(22, 0, 0).unwrap()); + assert_eq!(FXUnderlying::USDNOK.cutoff_utc(), NaiveTime::from_hms_opt(22, 0, 0).unwrap()); + assert_eq!(FXUnderlying::USDSEK.cutoff_utc(), NaiveTime::from_hms_opt(22, 0, 0).unwrap()); + } + + /// USDMXN (T+1) settlement dates — spot is T+1, noon NY (17:00 UTC) cut-off. + #[test] + fn test_settlement_date_usdmxn() -> Result<()> { + use crate::time::period::Period; + + let valuation_date = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); // Monday + + assert_eq!( + FXUnderlying::USDMXN.settlement_date(Period::SPOT, valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() // T+1 + ); + assert_eq!( + FXUnderlying::USDMXN.settlement_date(Period::ON, valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() // same as SPOT for T+1 + ); + assert_eq!( + FXUnderlying::USDMXN.settlement_date(Period::Weeks(1), valuation_date)?, + NaiveDate::from_ymd_opt(2023, 10, 24).unwrap() + ); + + Ok(()) + } + + /// USDTRY (T+1) cut-off time advances valuation date before noon Istanbul. + #[test] + fn test_effective_valuation_date_usdtry() { + let monday = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + + // Before USDTRY cut-off (09:00 UTC) → same day + let before = NaiveTime::from_hms_opt(8, 59, 0).unwrap(); + assert_eq!(FXUnderlying::USDTRY.effective_valuation_date(monday, before), monday); + + // At USDTRY cut-off → next business day + let at_cutoff = NaiveTime::from_hms_opt(9, 0, 0).unwrap(); + assert_eq!( + FXUnderlying::USDTRY.effective_valuation_date(monday, at_cutoff), + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + } + + /// USDRUB (T+1) cut-off is 12:30 Moscow = 09:30 UTC. + #[test] + fn test_effective_valuation_date_usdrub() { + let monday = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); + + let before = NaiveTime::from_hms_opt(9, 29, 0).unwrap(); + assert_eq!(FXUnderlying::USDRUB.effective_valuation_date(monday, before), monday); + + let at_cutoff = NaiveTime::from_hms_opt(9, 30, 0).unwrap(); + assert_eq!( + FXUnderlying::USDRUB.effective_valuation_date(monday, at_cutoff), + NaiveDate::from_ymd_opt(2023, 10, 17).unwrap() + ); + } + + #[test] + fn test_forward_points_converter_eurjpy() { + assert_eq!(FXUnderlying::EURJPY.forward_points_converter(), 100f64); + assert_eq!(FXUnderlying::EURCHF.forward_points_converter(), 10000f64); + } } From 6fb11902d95a73d7a5b26b26d95366c1497c87f1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 16:23:57 +0000 Subject: [PATCH 5/9] feat(fx): expand FXUnderlying enum with G10 crosses, EM pairs, and all calendar mappings Adds 39 new pairs: EUR/GBP/AUD/NZD crosses, EM USD pairs (SGD, HKD, CNY, PLN, HUF, CZK, ZAR, KRW, INR, BRL, DKK, IDR, TWD, THB, ILS, RON) and minor crosses (CHFJPY, CADCHF). Extends currency_to_country to cover all new currency codes via existing calendar implementations. AUDJPY, NZDJPY, CHFJPY added to the /100 forward-points converter group. All new T+2 pairs fall through to the existing wildcard in settles() and cutoff_utc(). Co-authored-by: Jeremy Wang --- src/derivatives/forex/basic.rs | 68 ++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/src/derivatives/forex/basic.rs b/src/derivatives/forex/basic.rs index 7b4beb6..0567eaf 100644 --- a/src/derivatives/forex/basic.rs +++ b/src/derivatives/forex/basic.rs @@ -2,8 +2,10 @@ use crate::error::Result; use crate::markets::forex::market_context::FxMarketContext; use crate::markets::forex::quotes::forwardpoints::{FXForwardHelper, FXForwardQuote}; use crate::time::calendars::{ - Australia, Calendar, Canada, Japan, JointCalendar, Mexico, NewZealand, Norway, Russia, Sweden, - Switzerland, Target, Turkey, UnitedKingdom, UnitedStates, + Australia, Brazil, Calendar, Canada, China, CzechRepublic, Denmark, HongKong, Hungary, India, + Indonesia, Israel, Japan, JointCalendar, Mexico, NewZealand, Norway, Poland, Romania, Russia, + Singapore, SouthAfrica, SouthKorea, Sweden, Switzerland, Taiwan, Target, Thailand, Turkey, + UnitedKingdom, UnitedStates, }; use crate::time::daycounters::DayCounters; use crate::time::daycounters::actual360::Actual360; @@ -25,10 +27,22 @@ pub enum FXUnderlying { EURCHF, EURNOK, EURSEK, + EURAUD, + EURNZD, + EURDKK, + EURPLN, + EURHUF, + EURCZK, + EURRON, // GBP crosses GBPUSD, GBPCAD, GBPJPY, + GBPAUD, + GBPNZD, + GBPCHF, + GBPNOK, + GBPSEK, // USD crosses — T+2 AUDUSD, NZDUSD, @@ -36,13 +50,42 @@ pub enum FXUnderlying { USDNOK, USDSEK, USDJPY, + USDSGD, + USDHKD, + USDCNY, + USDPLN, + USDHUF, + USDCZK, + USDZAR, + USDKRW, + USDINR, + USDBRL, + USDDKK, + USDIDR, + USDTWD, + USDTHB, + USDILS, + USDRON, // USD crosses — T+1 USDCAD, USDMXN, USDTRY, USDRUB, + // AUD crosses + AUDNZD, + AUDCAD, + AUDCHF, + AUDJPY, + AUDSGD, + // NZD crosses + NZDCAD, + NZDCHF, + NZDJPY, + NZDSGD, // other crosses CADJPY, + CHFJPY, + CADCHF, } impl FXUnderlying { @@ -61,6 +104,22 @@ impl FXUnderlying { Currency::MXN => Box::new(Mexico), Currency::TRY => Box::new(Turkey), Currency::RUB => Box::new(Russia::default()), + Currency::SGD => Box::new(Singapore), + Currency::HKD => Box::new(HongKong), + Currency::CNY => Box::new(China), + Currency::PLN => Box::new(Poland::default()), + Currency::HUF => Box::new(Hungary), + Currency::CZK => Box::new(CzechRepublic), + Currency::ZAR => Box::new(SouthAfrica), + Currency::KRW => Box::new(SouthKorea), + Currency::INR => Box::new(India), + Currency::BRL => Box::new(Brazil::default()), + Currency::DKK => Box::new(Denmark), + Currency::IDR => Box::new(Indonesia), + Currency::TWD => Box::new(Taiwan), + Currency::THB => Box::new(Thailand), + Currency::ILS => Box::new(Israel::default()), + Currency::RON => Box::new(Romania), _ => Box::new(Target), } } @@ -70,7 +129,10 @@ impl FXUnderlying { FXUnderlying::CADJPY | FXUnderlying::USDJPY | FXUnderlying::GBPJPY - | FXUnderlying::EURJPY => 100f64, + | FXUnderlying::EURJPY + | FXUnderlying::AUDJPY + | FXUnderlying::NZDJPY + | FXUnderlying::CHFJPY => 100f64, _ => 10000f64, } } From 268a64de34f488ef2d0ae58b9f8225d6afc37151 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 17:31:29 +0000 Subject: [PATCH 6/9] style: fix rustfmt formatting violations Co-authored-by: Jeremy Wang --- src/derivatives/forex/basic.rs | 54 ++++++++++++++++++----- src/markets/forex/quotes/forwardpoints.rs | 6 ++- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/src/derivatives/forex/basic.rs b/src/derivatives/forex/basic.rs index 0567eaf..6d840c5 100644 --- a/src/derivatives/forex/basic.rs +++ b/src/derivatives/forex/basic.rs @@ -147,7 +147,9 @@ impl FXUnderlying { pub fn settles(&self) -> i8 { match self { // T+1 settlement pairs - FXUnderlying::USDCAD | FXUnderlying::USDMXN | FXUnderlying::USDTRY + FXUnderlying::USDCAD + | FXUnderlying::USDMXN + | FXUnderlying::USDTRY | FXUnderlying::USDRUB => 1, _ => 2, } @@ -350,7 +352,10 @@ mod tests { FXUnderlying::USDCAD.near_date(Period::SN, valuation_date)?, Some(NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()) ); - assert_eq!(FXUnderlying::USDCAD.near_date(Period::Weeks(1), valuation_date)?, None); + assert_eq!( + FXUnderlying::USDCAD.near_date(Period::Weeks(1), valuation_date)?, + None + ); Ok(()) } @@ -362,7 +367,10 @@ mod tests { // Before USDCAD cut-off (noon NY = 17:00 UTC) → same day let before = NaiveTime::from_hms_opt(16, 59, 0).unwrap(); - assert_eq!(FXUnderlying::USDCAD.effective_valuation_date(monday, before), monday); + assert_eq!( + FXUnderlying::USDCAD.effective_valuation_date(monday, before), + monday + ); // At/after USDCAD cut-off → next business day (Tuesday) let after = NaiveTime::from_hms_opt(17, 0, 0).unwrap(); @@ -421,15 +429,33 @@ mod tests { #[test] fn test_cutoff_utc_all_pairs() { // noon New York (UTC-5 standard / UTC-4 DST → use UTC offset of 17:00 UTC as noon NY EDT) - assert_eq!(FXUnderlying::USDMXN.cutoff_utc(), NaiveTime::from_hms_opt(17, 0, 0).unwrap()); + assert_eq!( + FXUnderlying::USDMXN.cutoff_utc(), + NaiveTime::from_hms_opt(17, 0, 0).unwrap() + ); // noon Istanbul (UTC+3) = 09:00 UTC - assert_eq!(FXUnderlying::USDTRY.cutoff_utc(), NaiveTime::from_hms_opt(9, 0, 0).unwrap()); + assert_eq!( + FXUnderlying::USDTRY.cutoff_utc(), + NaiveTime::from_hms_opt(9, 0, 0).unwrap() + ); // 12:30 Moscow (UTC+3) = 09:30 UTC - assert_eq!(FXUnderlying::USDRUB.cutoff_utc(), NaiveTime::from_hms_opt(9, 30, 0).unwrap()); + assert_eq!( + FXUnderlying::USDRUB.cutoff_utc(), + NaiveTime::from_hms_opt(9, 30, 0).unwrap() + ); // standard London close - assert_eq!(FXUnderlying::AUDUSD.cutoff_utc(), NaiveTime::from_hms_opt(22, 0, 0).unwrap()); - assert_eq!(FXUnderlying::USDNOK.cutoff_utc(), NaiveTime::from_hms_opt(22, 0, 0).unwrap()); - assert_eq!(FXUnderlying::USDSEK.cutoff_utc(), NaiveTime::from_hms_opt(22, 0, 0).unwrap()); + assert_eq!( + FXUnderlying::AUDUSD.cutoff_utc(), + NaiveTime::from_hms_opt(22, 0, 0).unwrap() + ); + assert_eq!( + FXUnderlying::USDNOK.cutoff_utc(), + NaiveTime::from_hms_opt(22, 0, 0).unwrap() + ); + assert_eq!( + FXUnderlying::USDSEK.cutoff_utc(), + NaiveTime::from_hms_opt(22, 0, 0).unwrap() + ); } /// USDMXN (T+1) settlement dates — spot is T+1, noon NY (17:00 UTC) cut-off. @@ -462,7 +488,10 @@ mod tests { // Before USDTRY cut-off (09:00 UTC) → same day let before = NaiveTime::from_hms_opt(8, 59, 0).unwrap(); - assert_eq!(FXUnderlying::USDTRY.effective_valuation_date(monday, before), monday); + assert_eq!( + FXUnderlying::USDTRY.effective_valuation_date(monday, before), + monday + ); // At USDTRY cut-off → next business day let at_cutoff = NaiveTime::from_hms_opt(9, 0, 0).unwrap(); @@ -478,7 +507,10 @@ mod tests { let monday = NaiveDate::from_ymd_opt(2023, 10, 16).unwrap(); let before = NaiveTime::from_hms_opt(9, 29, 0).unwrap(); - assert_eq!(FXUnderlying::USDRUB.effective_valuation_date(monday, before), monday); + assert_eq!( + FXUnderlying::USDRUB.effective_valuation_date(monday, before), + monday + ); let at_cutoff = NaiveTime::from_hms_opt(9, 30, 0).unwrap(); assert_eq!( diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index 0779002..af65e3b 100644 --- a/src/markets/forex/quotes/forwardpoints.rs +++ b/src/markets/forex/quotes/forwardpoints.rs @@ -103,7 +103,11 @@ impl FXForwardHelper { .iter() .map(|q| { Ok(( - q.tenor.settlement_date_with_lag(self.valuation_date, calendar, self.spot_lag)?, + q.tenor.settlement_date_with_lag( + self.valuation_date, + calendar, + self.spot_lag, + )?, q.value, )) }) From 5952009976446a8f48f92f4c4e07b883192bc526 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 18:12:12 +0000 Subject: [PATCH 7/9] style: fix rustfmt violations in period.rs Reformat two ok_or_else closures and one assert_eq! call to satisfy the 100-column limit enforced by cargo fmt. Co-authored-by: Jeremy Wang --- src/time/period.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/time/period.rs b/src/time/period.rs index 5ea19ab..d2f0fe9 100644 --- a/src/time/period.rs +++ b/src/time/period.rs @@ -100,8 +100,9 @@ impl Period { calendar: &dyn Calendar, spot_lag: i64, ) -> Result { - let spot_offset = Duration::try_days(spot_lag) - .ok_or_else(|| Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")))?; + let spot_offset = Duration::try_days(spot_lag).ok_or_else(|| { + Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")) + })?; let target_date = match self { Period::ON => valuation_date, Period::TN => valuation_date + ONE_DAY, @@ -124,8 +125,9 @@ impl Period { calendar: &dyn Calendar, spot_lag: i64, ) -> Result> { - let spot_offset = Duration::try_days(spot_lag) - .ok_or_else(|| Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")))?; + let spot_offset = Duration::try_days(spot_lag).ok_or_else(|| { + Error::PeriodOutOfBounds(format!("{spot_lag} spot lag is out of bounds")) + })?; let raw = match self { Period::ON => Some(valuation_date), Period::TN => Some(valuation_date + ONE_DAY), @@ -362,7 +364,10 @@ mod tests { // Standard forward tenors have no near-leg (near = spot implicitly) assert_eq!(Period::Weeks(1).near_date(valuation_date, &calendar)?, None); - assert_eq!(Period::Months(1).near_date(valuation_date, &calendar)?, None); + assert_eq!( + Period::Months(1).near_date(valuation_date, &calendar)?, + None + ); Ok(()) } From a284577c50eedd9768d453f02d6e04109fe7f5d0 Mon Sep 17 00:00:00 2001 From: Jeremy Wang Date: Wed, 13 May 2026 22:03:23 +0100 Subject: [PATCH 8/9] version; --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2fad63b..336e251 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "finquant" -version = "0.0.59" +version = "0.0.60" authors = ["Jeremy Wang ", "David Steiner "] license = "MIT OR Apache-2.0" description = "Experimental Rust Quant Library" From 34a1328c367ab504a0f5f904d3915bc599b28ff8 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <41898282+claude[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 22:20:57 +0000 Subject: [PATCH 9/9] test(fx): add swap pricing and forward outright pricing tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two new tests to forwardpoints.rs that illustrate the distinction between the Bloomberg "Pts" (swap) and "Fwds" (outright) columns: - test_fx_swap_pricing_on_tn_sn: prices both legs of ON/TN/SN swaps via Period::near_date + Period::settlement_date, computing swap_pts = far_fwd_pts − near_fwd_pts for each tenor. - test_forward_outright_pricing: prices single-leg outright forwards at 1W/1M/3M/6M/1Y; outright = spot_ref + fwd_pts / converter. Co-authored-by: Jeremy Wang --- src/markets/forex/quotes/forwardpoints.rs | 111 ++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index af65e3b..7e6ddb7 100644 --- a/src/markets/forex/quotes/forwardpoints.rs +++ b/src/markets/forex/quotes/forwardpoints.rs @@ -455,6 +455,117 @@ mod tests { Ok(()) } + /// Prices both legs of ON / TN / SN FX swaps and derives swap forward points + /// (far_pts − near_pts) for each tenor. + /// + /// Bloomberg's "Pts" column shows the swap points for each individual overnight + /// period. The helper stores *outright* forward points from spot, so: + /// swap_pts = far_fwd_pts − near_fwd_pts + /// + /// | Swap | Near leg | Far leg | near_pts | far_pts | swap_pts | + /// |------|----------|------------|----------|---------|----------| + /// | ON | T (spot) | T+1 | 0.00 | −0.03 | −0.03 | + /// | TN | T+1 | T+2 (spot) | −0.03 | 0.00 | +0.03 | + /// | SN | T+2=spot | T+3 | 0.00 | +0.06 | +0.06 | + #[test] + fn test_fx_swap_pricing_on_tn_sn() -> Result<()> { + setup(); + // val = 2023-10-17 (Tue), spot_ref = 1.1 (GBPUSD-like, T+2 standard pair) + let helper = sample_fx_forward_helper(); + let calendar = JointCalendar::new(vec![ + Box::new(UnitedStates::default()), + Box::new(UnitedKingdom::default()), + ]); + let val = helper.valuation_date; // 2023-10-17 + + // ── ON swap: near = T (valuation_date), far = T+1 ──────────────────── + // The ON near leg falls on valuation_date; get_forward returns None there, + // so the near leg is priced at spot (0 outright forward pts). + let on_near = Period::ON.near_date(val, &calendar)?.unwrap(); + let on_far = Period::ON.settlement_date(val, &calendar)?; + assert_eq!(on_near, NaiveDate::from_ymd_opt(2023, 10, 17).unwrap()); + assert_eq!(on_far, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()); + // near is at spot → 0 outright forward pts; near == valuation_date → None + assert_eq!(helper.get_forward(on_near, &calendar)?, None); + let on_far_pts = helper.get_forward(on_far, &calendar)?.unwrap(); // ON quote: −0.03 + assert_eq!(on_far_pts, -0.03); + // swap_pts = far_pts − near_pts (near priced at spot → near_pts = 0) + let on_swap_pts = on_far_pts - 0.0_f64; + assert_eq!(on_swap_pts, -0.03); + + // ── TN swap: near = T+1, far = T+2 (spot) ──────────────────────────── + let tn_near = Period::TN.near_date(val, &calendar)?.unwrap(); + let tn_far = Period::TN.settlement_date(val, &calendar)?; + assert_eq!(tn_near, NaiveDate::from_ymd_opt(2023, 10, 18).unwrap()); + assert_eq!(tn_far, NaiveDate::from_ymd_opt(2023, 10, 19).unwrap()); + let tn_near_pts = helper.get_forward(tn_near, &calendar)?.unwrap(); // ON quote: −0.03 + let tn_far_pts = helper.get_forward(tn_far, &calendar)?.unwrap(); // SPOT quote: 0.0 + assert_eq!(tn_near_pts, -0.03); + assert_eq!(tn_far_pts, 0.0); + let tn_swap_pts = tn_far_pts - tn_near_pts; // 0.03 + assert!((tn_swap_pts - 0.03).abs() < f64::EPSILON); + + // ── SN swap: near = T+2 (spot), far = T+3 ──────────────────────────── + let sn_near = Period::SN.near_date(val, &calendar)?.unwrap(); + let sn_far = Period::SN.settlement_date(val, &calendar)?; + assert_eq!(sn_near, NaiveDate::from_ymd_opt(2023, 10, 19).unwrap()); + assert_eq!(sn_far, NaiveDate::from_ymd_opt(2023, 10, 20).unwrap()); + let sn_near_pts = helper.get_forward(sn_near, &calendar)?.unwrap(); // SPOT quote: 0.0 + let sn_far_pts = helper.get_forward(sn_far, &calendar)?.unwrap(); // SN quote: 0.06 + assert_eq!(sn_near_pts, 0.0); + assert_eq!(sn_far_pts, 0.06); + let sn_swap_pts = sn_far_pts - sn_near_pts; // 0.06 + assert_eq!(sn_swap_pts, 0.06); + + Ok(()) + } + + /// Prices pure forward outright contracts at standard tenors. + /// + /// A forward outright is a single cash exchange at the far-leg settlement date + /// (no near leg). This is what Bloomberg's "Fwds" column shows. + /// + /// outright_rate = spot_ref + forward_pts / converter + /// + /// | Tenor | Fwd pts | Outright (spot = 1.1, converter = 10000) | + /// |-------|---------|------------------------------------------| + /// | 1W | 0.39 | 1.100039 | + /// | 1M | 1.83 | 1.100183 | + /// | 3M | 8.05 | 1.100805 | + /// | 6M | 13.12 | 1.101312 | + /// | 1Y | 16.18 | 1.101618 | + #[test] + fn test_forward_outright_pricing() -> Result<()> { + setup(); + let helper = sample_fx_forward_helper(); // val = 2023-10-17, spot_ref = 1.1 + let converter = 10_000_f64; // GBPUSD-like: 4 decimal places + let calendar = JointCalendar::new(vec![ + Box::new(UnitedStates::default()), + Box::new(UnitedKingdom::default()), + ]); + let val = helper.valuation_date; + + let cases: &[(Period, f64, f64)] = &[ + (Period::Weeks(1), 0.39, 1.100_039), + (Period::Months(1), 1.83, 1.100_183), + (Period::Months(3), 8.05, 1.100_805), + (Period::Months(6), 13.12, 1.101_312), + (Period::Years(1), 16.18, 1.101_618), + ]; + for &(tenor, expected_pts, expected_outright) in cases { + let settle = tenor.settlement_date(val, &calendar)?; + let pts = helper.get_forward(settle, &calendar)?.unwrap(); + assert_eq!(pts, expected_pts, "fwd pts mismatch for {tenor:?}"); + let outright = helper.spot_ref + pts / converter; + assert!( + (outright - expected_outright).abs() < 1e-9, + "outright mismatch for {tenor:?}: got {outright}, expected {expected_outright}" + ); + } + + Ok(()) + } + #[test] fn test_notify_observers_prune_and_notify() -> Result<()> { // Create one dead observer (dropped before notification) and one live observer.