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" diff --git a/src/derivatives/forex/basic.rs b/src/derivatives/forex/basic.rs index 82bfc06..6d840c5 100644 --- a/src/derivatives/forex/basic.rs +++ b/src/derivatives/forex/basic.rs @@ -1,12 +1,17 @@ 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, 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; 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; @@ -14,16 +19,73 @@ use strum_macros::{Display, EnumString}; #[derive(Deserialize, Serialize, Display, EnumString, Debug)] pub enum FXUnderlying { + // EUR crosses EURGBP, EURUSD, EURCAD, EURJPY, + EURCHF, + EURNOK, + EURSEK, + EURAUD, + EURNZD, + EURDKK, + EURPLN, + EURHUF, + EURCZK, + EURRON, + // GBP crosses GBPUSD, GBPCAD, GBPJPY, - USDCAD, + GBPAUD, + GBPNZD, + GBPCHF, + GBPNOK, + GBPSEK, + // USD crosses — T+2 + AUDUSD, + NZDUSD, + USDCHF, + 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 { @@ -34,15 +96,43 @@ 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()), + 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), } } 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 + | FXUnderlying::AUDJPY + | FXUnderlying::NZDJPY + | FXUnderlying::CHFJPY => 100f64, _ => 10000f64, } } @@ -56,13 +146,91 @@ 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, } } - 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. + /// + /// | 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 | 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(), + } + } + + /// 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 +301,99 @@ 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,11 +402,126 @@ 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(()) } + + #[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); + } } diff --git a/src/markets/forex/quotes/forwardpoints.rs b/src/markets/forex/quotes/forwardpoints.rs index 34409cb..7e6ddb7 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, @@ -18,16 +56,32 @@ pub struct FXForwardQuote { pub struct FXForwardHelper { pub valuation_date: NaiveDate, pub spot_ref: f64, + spot_lag: i64, 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 with an explicit 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: i64, + quotes: Vec, + ) -> Self { Self { valuation_date, spot_ref, + spot_lag, quotes, observers: RefCell::new(Vec::new()), } @@ -49,7 +103,11 @@ impl FXForwardHelper { .iter() .map(|q| { Ok(( - q.tenor.settlement_date(self.valuation_date, calendar)?, + q.tenor.settlement_date_with_lag( + self.valuation_date, + calendar, + self.spot_lag, + )?, q.value, )) }) @@ -218,6 +276,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() @@ -392,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. 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..d2f0fe9 100644 --- a/src/time/period.rs +++ b/src/time/period.rs @@ -13,9 +13,25 @@ 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 (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. +/// +/// 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,15 +41,72 @@ pub enum Period { } impl Period { + /// 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+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, ) -> Result { - // TODO: Change spot as T+2 to be linked to currency. + 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")) + })?; let target_date = match self { Period::ON => valuation_date, - _ => valuation_date + TWO_DAYS, + Period::TN => valuation_date + ONE_DAY, + _ => valuation_date + spot_offset, }; let mut settlement_date = (target_date + *self)?; if settlement_date >= calendar.end_of_month(settlement_date) { @@ -42,9 +115,35 @@ impl Period { while !calendar.is_business_day(settlement_date) { settlement_date += ONE_DAY; } - Ok(settlement_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: 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 raw = match self { + Period::ON => Some(valuation_date), + Period::TN => Some(valuation_date + ONE_DAY), + Period::SN => Some(valuation_date + spot_offset), + _ => 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 +152,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 +179,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 +206,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 +230,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 +266,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 +301,74 @@ 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, T+2 spot). + /// + /// 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 (T+2 pair). + #[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(()) + } }