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