Skip to main content

fin_stream/session/
mod.rs

1//! Market session awareness — trading hours, holidays, status transitions.
2//!
3//! ## Responsibility
4//! Classify a UTC timestamp into a market trading status for a given session
5//! (equity, crypto, forex). Enables downstream filtering of ticks by session.
6//!
7//! ## Guarantees
8//! - Pure functions: SessionAwareness::status() is deterministic and stateless
9//! - Non-panicking: all operations return Result or TradingStatus
10
11use crate::error::StreamError;
12
13/// Broad category of market session.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
15pub enum MarketSession {
16    /// US equity market (NYSE/NASDAQ) — 9:30–16:00 ET Mon–Fri.
17    UsEquity,
18    /// Crypto market — 24/7/365.
19    Crypto,
20    /// Forex market — 24/5, Sunday 17:00 ET – Friday 17:00 ET.
21    Forex,
22}
23
24/// Trading status at a point in time.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
26pub enum TradingStatus {
27    /// Regular trading hours.
28    Open,
29    /// Pre-market or after-hours for equity; equivalent to Open for crypto.
30    Extended,
31    /// Market is closed.
32    Closed,
33}
34
35/// Determines trading status for a market session.
36pub struct SessionAwareness {
37    session: MarketSession,
38}
39
40impl SessionAwareness {
41    pub fn new(session: MarketSession) -> Self {
42        Self { session }
43    }
44
45    /// Classify a UTC timestamp (ms) into a trading status.
46    pub fn status(&self, utc_ms: u64) -> Result<TradingStatus, StreamError> {
47        match self.session {
48            MarketSession::Crypto => Ok(TradingStatus::Open),
49            MarketSession::UsEquity => self.us_equity_status(utc_ms),
50            MarketSession::Forex => self.forex_status(utc_ms),
51        }
52    }
53
54    pub fn session(&self) -> MarketSession { self.session }
55
56    fn us_equity_status(&self, utc_ms: u64) -> Result<TradingStatus, StreamError> {
57        // ET = UTC - 5h (EST). Compute time-of-day in ET by taking UTC time-within-day
58        // and subtracting 5h with modular wraparound (avoids epoch-day boundary errors).
59        // For production DST support, integrate chrono-tz.
60        const DAY_MS: u64 = 24 * 3600 * 1000;
61        const ET_OFFSET_MS: u64 = 5 * 3600 * 1000; // EST = UTC-5
62        let utc_day_ms = utc_ms % DAY_MS;
63        let day_ms = (utc_day_ms + DAY_MS - ET_OFFSET_MS) % DAY_MS;
64        let day_of_week = (utc_ms / DAY_MS + 4) % 7; // 0=Sun, 1=Mon, ..., 6=Sat
65
66        // Weekend: Saturday=6, Sunday=0
67        if day_of_week == 0 || day_of_week == 6 {
68            return Ok(TradingStatus::Closed);
69        }
70
71        let open_ms = (9 * 3600 + 30 * 60) * 1000;   // 9:30 ET
72        let close_ms = 16 * 3600 * 1000;              // 16:00 ET
73        let pre_ms = 4 * 3600 * 1000;                 // 4:00 ET
74        let post_ms = 20 * 3600 * 1000;               // 20:00 ET
75
76        if day_ms >= open_ms && day_ms < close_ms {
77            Ok(TradingStatus::Open)
78        } else if (day_ms >= pre_ms && day_ms < open_ms) || (day_ms >= close_ms && day_ms < post_ms) {
79            Ok(TradingStatus::Extended)
80        } else {
81            Ok(TradingStatus::Closed)
82        }
83    }
84
85    fn forex_status(&self, utc_ms: u64) -> Result<TradingStatus, StreamError> {
86        // Forex: open Sunday 22:00 UTC – Friday 22:00 UTC (approximately)
87        let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; // 0=Sun, 1=Mon, ..., 6=Sat
88        let day_ms = utc_ms % (24 * 3600 * 1000);
89        let hour_22_ms = 22 * 3600 * 1000;
90
91        // Fully closed: Saturday entire day, Sunday before 22:00
92        if day_of_week == 6 {
93            return Ok(TradingStatus::Closed);
94        }
95        if day_of_week == 0 && day_ms < hour_22_ms {
96            return Ok(TradingStatus::Closed);
97        }
98        // Friday after 22:00 UTC — also closed
99        if day_of_week == 5 && day_ms >= hour_22_ms {
100            return Ok(TradingStatus::Closed);
101        }
102        Ok(TradingStatus::Open)
103    }
104}
105
106/// Convenience: check if a session is currently tradeable.
107pub fn is_tradeable(session: MarketSession, utc_ms: u64) -> Result<bool, StreamError> {
108    let sa = SessionAwareness::new(session);
109    let status = sa.status(utc_ms)?;
110    Ok(status == TradingStatus::Open || status == TradingStatus::Extended)
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    // Reference Monday 2024-01-08 14:30 UTC = 09:30 ET (market open)
118    // UTC ms: 1704724200000 — Mon Jan 08 2024 14:30:00 UTC
119    const MON_OPEN_UTC_MS: u64 = 1704724200000;
120    // Monday 21:00 UTC = 16:00 ET (market close, extended hours start)
121    const MON_CLOSE_UTC_MS: u64 = 1704747600000;
122    // Saturday 2024-01-13
123    const SAT_UTC_MS: u64 = 1705104000000;
124    // Sunday 2024-01-07 10:00 UTC (before 22:00 UTC, forex closed)
125    const SUN_BEFORE_UTC_MS: u64 = 1704621600000;
126
127    fn sa(session: MarketSession) -> SessionAwareness {
128        SessionAwareness::new(session)
129    }
130
131    #[test]
132    fn test_crypto_always_open() {
133        let sa = sa(MarketSession::Crypto);
134        assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
135        assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Open);
136        assert_eq!(sa.status(0).unwrap(), TradingStatus::Open);
137    }
138
139    #[test]
140    fn test_us_equity_open_during_market_hours() {
141        let sa = sa(MarketSession::UsEquity);
142        // 14:30 UTC = 09:30 ET Monday
143        assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
144    }
145
146    #[test]
147    fn test_us_equity_closed_after_hours() {
148        let sa = sa(MarketSession::UsEquity);
149        // 21:00 UTC = 16:00 ET = market close, after-hours starts
150        let status = sa.status(MON_CLOSE_UTC_MS).unwrap();
151        assert!(status == TradingStatus::Extended || status == TradingStatus::Closed);
152    }
153
154    #[test]
155    fn test_us_equity_closed_on_saturday() {
156        let sa = sa(MarketSession::UsEquity);
157        assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Closed);
158    }
159
160    #[test]
161    fn test_us_equity_premarket_extended() {
162        let sa = sa(MarketSession::UsEquity);
163        // Monday 09:00 UTC = 04:00 ET (pre-market) = 1704672000000 + 9*3600*1000
164        let pre_ms: u64 = 1704704400000; // Mon Jan 08 2024 09:00 UTC
165        let status = sa.status(pre_ms).unwrap();
166        assert!(status == TradingStatus::Extended || status == TradingStatus::Open);
167    }
168
169    #[test]
170    fn test_forex_open_on_monday() {
171        let sa = sa(MarketSession::Forex);
172        assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
173    }
174
175    #[test]
176    fn test_forex_closed_on_saturday() {
177        let sa = sa(MarketSession::Forex);
178        assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Closed);
179    }
180
181    #[test]
182    fn test_forex_closed_sunday_before_22_utc() {
183        let sa = sa(MarketSession::Forex);
184        assert_eq!(sa.status(SUN_BEFORE_UTC_MS).unwrap(), TradingStatus::Closed);
185    }
186
187    #[test]
188    fn test_is_tradeable_crypto_always_true() {
189        assert!(is_tradeable(MarketSession::Crypto, SAT_UTC_MS).unwrap());
190    }
191
192    #[test]
193    fn test_is_tradeable_equity_open() {
194        assert!(is_tradeable(MarketSession::UsEquity, MON_OPEN_UTC_MS).unwrap());
195    }
196
197    #[test]
198    fn test_is_tradeable_equity_weekend_false() {
199        assert!(!is_tradeable(MarketSession::UsEquity, SAT_UTC_MS).unwrap());
200    }
201
202    #[test]
203    fn test_session_accessor() {
204        let sa = sa(MarketSession::Crypto);
205        assert_eq!(sa.session(), MarketSession::Crypto);
206    }
207
208    #[test]
209    fn test_market_session_equality() {
210        assert_eq!(MarketSession::Crypto, MarketSession::Crypto);
211        assert_ne!(MarketSession::Crypto, MarketSession::Forex);
212    }
213
214    #[test]
215    fn test_trading_status_equality() {
216        assert_eq!(TradingStatus::Open, TradingStatus::Open);
217        assert_ne!(TradingStatus::Open, TradingStatus::Closed);
218    }
219}