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