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//! - DST-aware: US equity hours correctly switch between EST (UTC-5) and
11//!   EDT (UTC-4) on the second Sunday of March and first Sunday of November
12
13use crate::error::StreamError;
14use chrono::{Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc, Weekday};
15
16/// Broad category of market session.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18pub enum MarketSession {
19    /// US equity market (NYSE/NASDAQ) — 9:30–16:00 ET Mon–Fri.
20    UsEquity,
21    /// Crypto market — 24/7/365, always open.
22    Crypto,
23    /// Forex market — 24/5, Sunday 22:00 UTC – Friday 22:00 UTC.
24    Forex,
25}
26
27/// Trading status at a point in time.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub enum TradingStatus {
30    /// Regular trading hours are active.
31    Open,
32    /// Pre-market or after-hours session (equity); equivalent to `Open` for crypto.
33    Extended,
34    /// Market is fully closed; no trading possible.
35    Closed,
36}
37
38impl MarketSession {
39    /// Duration of one regular trading session in milliseconds.
40    ///
41    /// - `UsEquity`: 9:30–16:00 ET = 6.5 hours = 23,400,000 ms
42    /// - `Forex`: continuous 24/5 week = 120 hours = 432,000,000 ms
43    /// - `Crypto`: always open — returns `u64::MAX`
44    pub fn session_duration_ms(self) -> u64 {
45        match self {
46            MarketSession::UsEquity => 6 * 3_600_000 + 30 * 60_000, // 6.5 hours
47            MarketSession::Forex => 5 * 24 * 3_600_000,              // 120 hours
48            MarketSession::Crypto => u64::MAX,
49        }
50    }
51
52    /// Returns `true` if this session has a defined extended-hours trading period.
53    ///
54    /// Only [`MarketSession::UsEquity`] has pre-market (4:00–9:30 ET) and
55    /// after-hours (16:00–20:00 ET) periods. Crypto is always open; Forex has
56    /// no separate extended session.
57    pub fn has_extended_hours(self) -> bool {
58        matches!(self, MarketSession::UsEquity)
59    }
60}
61
62impl std::fmt::Display for MarketSession {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            MarketSession::UsEquity => write!(f, "UsEquity"),
66            MarketSession::Crypto => write!(f, "Crypto"),
67            MarketSession::Forex => write!(f, "Forex"),
68        }
69    }
70}
71
72impl std::fmt::Display for TradingStatus {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            TradingStatus::Open => write!(f, "Open"),
76            TradingStatus::Extended => write!(f, "Extended"),
77            TradingStatus::Closed => write!(f, "Closed"),
78        }
79    }
80}
81
82impl std::str::FromStr for MarketSession {
83    type Err = StreamError;
84
85    /// Parse a market session name (case-insensitive).
86    ///
87    /// Accepted values: `"usequity"`, `"crypto"`, `"forex"`.
88    fn from_str(s: &str) -> Result<Self, Self::Err> {
89        match s.to_lowercase().as_str() {
90            "usequity" => Ok(MarketSession::UsEquity),
91            "crypto" => Ok(MarketSession::Crypto),
92            "forex" => Ok(MarketSession::Forex),
93            _ => Err(StreamError::ConfigError {
94                reason: format!("unknown market session '{s}'; expected usequity, crypto, or forex"),
95            }),
96        }
97    }
98}
99
100impl std::str::FromStr for TradingStatus {
101    type Err = StreamError;
102
103    /// Parse a trading status string (case-insensitive).
104    ///
105    /// Accepted values: `"open"`, `"extended"`, `"closed"`.
106    fn from_str(s: &str) -> Result<Self, Self::Err> {
107        match s.to_lowercase().as_str() {
108            "open" => Ok(TradingStatus::Open),
109            "extended" => Ok(TradingStatus::Extended),
110            "closed" => Ok(TradingStatus::Closed),
111            _ => Err(StreamError::ConfigError {
112                reason: format!(
113                    "unknown trading status '{s}'; expected open, extended, or closed"
114                ),
115            }),
116        }
117    }
118}
119
120/// Determines trading status for a market session.
121pub struct SessionAwareness {
122    session: MarketSession,
123}
124
125impl SessionAwareness {
126    /// Create a session classifier for the given market.
127    pub fn new(session: MarketSession) -> Self {
128        Self { session }
129    }
130
131    /// Returns `true` if `utc_ms` falls on a Saturday or Sunday (UTC).
132    ///
133    /// Session-agnostic: always checks the UTC calendar day regardless of
134    /// the configured [`MarketSession`]. Useful for skip-weekend logic in
135    /// backtesting loops and scheduling.
136    pub fn is_weekend(utc_ms: u64) -> bool {
137        let dt = Utc.timestamp_millis_opt(utc_ms as i64).unwrap();
138        let weekday = dt.weekday();
139        weekday == Weekday::Sat || weekday == Weekday::Sun
140    }
141
142    /// Classify a UTC timestamp (ms) into a trading status.
143    pub fn status(&self, utc_ms: u64) -> Result<TradingStatus, StreamError> {
144        match self.session {
145            MarketSession::Crypto => Ok(TradingStatus::Open),
146            MarketSession::UsEquity => Ok(self.us_equity_status(utc_ms)),
147            MarketSession::Forex => Ok(self.forex_status(utc_ms)),
148        }
149    }
150
151    /// The market session this classifier was constructed for.
152    pub fn session(&self) -> MarketSession {
153        self.session
154    }
155
156    /// Return the UTC millisecond timestamp of the next time this session
157    /// enters [`TradingStatus::Open`] status.
158    ///
159    /// If the session is already `Open` at `utc_ms`, returns `utc_ms`
160    /// unchanged. For [`MarketSession::Crypto`] this always returns `utc_ms`.
161    pub fn next_open_ms(&self, utc_ms: u64) -> u64 {
162        match self.session {
163            MarketSession::Crypto => utc_ms,
164            MarketSession::Forex => self.next_forex_open_ms(utc_ms),
165            MarketSession::UsEquity => self.next_us_equity_open_ms(utc_ms),
166        }
167    }
168
169    /// Returns `true` if the session is currently in [`TradingStatus::Closed`] status.
170    ///
171    /// Shorthand for `self.status(utc_ms) == Ok(TradingStatus::Closed)`.
172    /// For [`MarketSession::Crypto`] this always returns `false`.
173    pub fn is_closed(&self, utc_ms: u64) -> bool {
174        self.status(utc_ms).map_or(false, |s| s == TradingStatus::Closed)
175    }
176
177    /// Returns `true` if the session is currently in [`TradingStatus::Extended`] status.
178    ///
179    /// For [`MarketSession::Crypto`] this always returns `false` (crypto is always
180    /// `Open`). For equity, `Extended` covers pre-market (4:00–9:30 ET) and
181    /// after-hours (16:00–20:00 ET).
182    pub fn is_extended(&self, utc_ms: u64) -> bool {
183        self.status(utc_ms).map_or(false, |s| s == TradingStatus::Extended)
184    }
185
186    /// Returns `true` if the session is currently in [`TradingStatus::Open`] status.
187    ///
188    /// Shorthand for `self.status(utc_ms).map(|s| s == TradingStatus::Open).unwrap_or(false)`.
189    /// For [`MarketSession::Crypto`] this always returns `true`.
190    pub fn is_open(&self, utc_ms: u64) -> bool {
191        self.status(utc_ms).map_or(false, |s| s == TradingStatus::Open)
192    }
193
194    /// Returns `true` if the session is currently tradeable: either
195    /// [`TradingStatus::Open`] or [`TradingStatus::Extended`].
196    ///
197    /// For [`MarketSession::Crypto`] this always returns `true`. For equity,
198    /// returns `true` during both regular hours and extended (pre/after-market)
199    /// hours.
200    pub fn is_market_hours(&self, utc_ms: u64) -> bool {
201        self.is_active(utc_ms)
202    }
203
204    /// Returns the number of whole minutes until the next [`TradingStatus::Open`] transition.
205    ///
206    /// Returns `0` if the session is already open. For [`MarketSession::Crypto`] always
207    /// returns `0`. Useful for scheduling reconnect timers and pre-open setup.
208    pub fn minutes_until_open(&self, utc_ms: u64) -> u64 {
209        self.time_until_open_ms(utc_ms) / 60_000
210    }
211
212    /// Milliseconds until the next [`TradingStatus::Open`] transition.
213    ///
214    /// Returns `0` if the session is already `Open`. For
215    /// [`MarketSession::Crypto`] always returns `0`.
216    pub fn time_until_open_ms(&self, utc_ms: u64) -> u64 {
217        self.next_open_ms(utc_ms).saturating_sub(utc_ms)
218    }
219
220    /// Seconds until the session next opens as `f64`.
221    ///
222    /// Returns `0.0` if the session is already open.
223    pub fn seconds_until_open(&self, utc_ms: u64) -> f64 {
224        self.time_until_open_ms(utc_ms) as f64 / 1_000.0
225    }
226
227    /// Whole minutes until the session enters [`TradingStatus::Closed`].
228    ///
229    /// Returns `0` if the session is already closed or will close in less than
230    /// one minute. For [`MarketSession::Crypto`] returns `u64::MAX` (never
231    /// closes). Useful for scheduling pre-close warnings or cooldown timers.
232    pub fn minutes_until_close(&self, utc_ms: u64) -> u64 {
233        let ms = self.time_until_close_ms(utc_ms);
234        if ms == u64::MAX {
235            return u64::MAX;
236        }
237        ms / 60_000
238    }
239
240    /// Milliseconds until the session enters [`TradingStatus::Closed`].
241    ///
242    /// Returns `0` if the session is already `Closed`. For
243    /// [`MarketSession::Crypto`] returns `u64::MAX` (never closes).
244    pub fn time_until_close_ms(&self, utc_ms: u64) -> u64 {
245        let close = self.next_close_ms(utc_ms);
246        if close == u64::MAX {
247            u64::MAX
248        } else {
249            close.saturating_sub(utc_ms)
250        }
251    }
252
253    /// Returns the number of complete bars of `bar_duration_ms` that fit before the next open.
254    ///
255    /// Returns `0` if the session is already open or `bar_duration_ms == 0`.
256    /// Useful for scheduling reconnects or pre-open setup tasks.
257    pub fn bars_until_open(&self, utc_ms: u64, bar_duration_ms: u64) -> u64 {
258        if bar_duration_ms == 0 || self.is_open(utc_ms) {
259            return 0;
260        }
261        let ms_until = self.time_until_open_ms(utc_ms);
262        ms_until / bar_duration_ms
263    }
264
265    /// Returns `true` if the equity session is currently in the pre-market window (4:00–9:30 ET).
266    ///
267    /// Always returns `false` for non-equity sessions. Pre-market is a subset of
268    /// [`TradingStatus::Extended`]; this method distinguishes it from after-hours.
269    pub fn is_pre_market(&self, utc_ms: u64) -> bool {
270        if self.session != MarketSession::UsEquity {
271            return false;
272        }
273        let Some(t) = self.et_trading_secs_of_day(utc_ms) else { return false; };
274        let pre_open = 4 * 3600_u64;
275        let market_open = 9 * 3600 + 30 * 60_u64;
276        t >= pre_open && t < market_open
277    }
278
279    /// Returns `true` if the equity session is in the after-hours window (16:00–20:00 ET).
280    ///
281    /// Always returns `false` for non-equity sessions. After-hours is a subset of
282    /// [`TradingStatus::Extended`]; this method distinguishes it from pre-market.
283    pub fn is_after_hours(&self, utc_ms: u64) -> bool {
284        if self.session != MarketSession::UsEquity {
285            return false;
286        }
287        let Some(t) = self.et_trading_secs_of_day(utc_ms) else { return false; };
288        let market_close = 16 * 3600_u64;
289        let post_close = 20 * 3600_u64;
290        t >= market_close && t < post_close
291    }
292
293    /// Returns the ET second-of-day for `utc_ms`, or `None` on weekends / market holidays.
294    fn et_trading_secs_of_day(&self, utc_ms: u64) -> Option<u64> {
295        let secs = (utc_ms / 1000) as i64;
296        let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
297            .unwrap_or_else(chrono::Utc::now);
298        let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
299        let et_dt = dt + chrono::Duration::seconds(et_offset_secs);
300        let dow = et_dt.weekday();
301        if dow == chrono::Weekday::Sat || dow == chrono::Weekday::Sun {
302            return None;
303        }
304        if is_us_market_holiday(et_dt.date_naive()) {
305            return None;
306        }
307        Some(et_dt.num_seconds_from_midnight() as u64)
308    }
309
310    /// Returns `true` if the current time is in extended hours (pre-market or
311    /// after-hours) for `UsEquity`. Always `false` for other sessions.
312    pub fn is_extended_hours(&self, utc_ms: u64) -> bool {
313        self.is_pre_market(utc_ms) || self.is_after_hours(utc_ms)
314    }
315
316    /// Returns `true` if the session is active: either `Open` or `Extended` (not `Closed`).
317    pub fn is_active(&self, utc_ms: u64) -> bool {
318        matches!(self.status(utc_ms), Ok(TradingStatus::Open) | Ok(TradingStatus::Extended))
319    }
320
321    /// Return the UTC millisecond timestamp of the next time this session
322    /// enters [`TradingStatus::Closed`] status.
323    ///
324    /// If the session is already `Closed`, returns `utc_ms` unchanged.
325    /// For [`MarketSession::Crypto`], which never closes, returns `u64::MAX`.
326    pub fn next_close_ms(&self, utc_ms: u64) -> u64 {
327        match self.session {
328            MarketSession::Crypto => u64::MAX,
329            MarketSession::Forex => self.next_forex_close_ms(utc_ms),
330            MarketSession::UsEquity => self.next_us_equity_close_ms(utc_ms),
331        }
332    }
333
334    /// Returns a human-readable label for the current session status.
335    ///
336    /// For `UsEquity`: `"open"`, `"pre-market"`, `"after-hours"`, or `"closed"`.
337    /// For `Crypto`: always `"open"`. For `Forex`: `"open"` or `"closed"`.
338    pub fn session_label(&self, utc_ms: u64) -> &'static str {
339        match self.session {
340            MarketSession::Crypto => "open",
341            MarketSession::Forex => {
342                if self.is_open(utc_ms) { "open" } else { "closed" }
343            }
344            MarketSession::UsEquity => {
345                if self.is_open(utc_ms) {
346                    "open"
347                } else if self.is_pre_market(utc_ms) {
348                    "pre-market"
349                } else if self.is_after_hours(utc_ms) {
350                    "after-hours"
351                } else {
352                    "closed"
353                }
354            }
355        }
356    }
357
358    /// Returns `true` only during regular (non-extended) open hours.
359    ///
360    /// For `UsEquity`: `true` when 9:30–16:00 ET on a trading day.
361    /// For `Crypto`: always `true`. For `Forex`: same as `is_open`.
362    pub fn is_liquid(&self, utc_ms: u64) -> bool {
363        self.is_open(utc_ms)
364    }
365
366    /// Fraction `[0.0, 1.0]` of the current trading session elapsed at `utc_ms`.
367    ///
368    /// Returns `None` when:
369    /// - The session is not currently [`TradingStatus::Open`].
370    /// - The session never closes (e.g. [`MarketSession::Crypto`]).
371    ///
372    /// Returns `0.0` at the exact session open and `1.0` at the session close.
373    /// Values are clamped to `[0.0, 1.0]`.
374    pub fn session_progress(&self, utc_ms: u64) -> Option<f64> {
375        if !self.is_open(utc_ms) {
376            return None;
377        }
378        let duration_ms = self.session.session_duration_ms();
379        if duration_ms == u64::MAX {
380            return None; // Crypto never closes
381        }
382        // Find the open time by locating the next open after (utc_ms - duration_ms).
383        // Since is_open is true, the session opened within the last duration_ms.
384        let look_before = utc_ms.saturating_sub(duration_ms);
385        let open_ms = self.next_open_ms(look_before);
386        let elapsed = utc_ms.saturating_sub(open_ms);
387        Some((elapsed as f64 / duration_ms as f64).clamp(0.0, 1.0))
388    }
389
390    /// Milliseconds elapsed since the current session opened.
391    ///
392    /// Returns `None` if the session is not currently [`TradingStatus::Open`] or
393    /// if the session never closes (e.g. [`MarketSession::Crypto`]).
394    ///
395    /// At session open this returns `0`; this is the absolute counterpart to the
396    /// fractional [`session_progress`](Self::session_progress).
397    pub fn time_in_session_ms(&self, utc_ms: u64) -> Option<u64> {
398        if !self.is_open(utc_ms) {
399            return None;
400        }
401        let duration_ms = self.session.session_duration_ms();
402        if duration_ms == u64::MAX {
403            return None;
404        }
405        let look_before = utc_ms.saturating_sub(duration_ms);
406        let open_ms = self.next_open_ms(look_before);
407        Some(utc_ms.saturating_sub(open_ms))
408    }
409
410    /// Milliseconds remaining until the current session closes.
411    ///
412    /// Returns `None` if the session is not currently [`TradingStatus::Open`] or
413    /// if the session never closes (e.g. [`MarketSession::Crypto`]).
414    ///
415    /// This is the complement of [`time_in_session_ms`](Self::time_in_session_ms):
416    /// `remaining + elapsed == session_duration_ms`.
417    pub fn remaining_session_ms(&self, utc_ms: u64) -> Option<u64> {
418        let elapsed = self.time_in_session_ms(utc_ms)?;
419        let duration_ms = self.session.session_duration_ms();
420        Some(duration_ms.saturating_sub(elapsed))
421    }
422
423    /// UTC fraction of the 24-hour day elapsed at `utc_ms`.
424    ///
425    /// Returns a value in `[0.0, 1.0)` representing how far through the calendar
426    /// day the timestamp is: `0.0` at midnight UTC, approaching `1.0` just before
427    /// the next midnight. Independent of session status.
428    pub fn fraction_of_day_elapsed(&self, utc_ms: u64) -> f64 {
429        const MS_PER_DAY: f64 = 24.0 * 60.0 * 60.0 * 1000.0;
430        let ms_in_day = utc_ms % (24 * 60 * 60 * 1000);
431        ms_in_day as f64 / MS_PER_DAY
432    }
433
434    /// Minutes elapsed since the current session opened.
435    ///
436    /// Returns `0` when the market is closed or for sessions without a defined
437    /// open time (Crypto). Rounds down.
438    pub fn minutes_since_open(&self, utc_ms: u64) -> u64 {
439        self.time_in_session_ms(utc_ms)
440            .map(|ms| ms / 60_000)
441            .unwrap_or(0)
442    }
443
444    /// Milliseconds remaining until the session closes, or `None` when the
445    /// session is not currently in regular trading hours.
446    ///
447    /// Unlike [`time_until_close_ms`](Self::time_until_close_ms) (which returns
448    /// `u64::MAX` for always-open sessions and `0` when closed), this returns
449    /// `None` for both the closed and always-open cases.
450    pub fn remaining_until_close_ms(&self, utc_ms: u64) -> Option<u64> {
451        if !self.is_regular_session(utc_ms) {
452            return None;
453        }
454        let close = self.next_close_ms(utc_ms);
455        if close == u64::MAX {
456            return None;
457        }
458        Some(close.saturating_sub(utc_ms))
459    }
460
461    /// Returns `true` if the session is currently in the pre-open (pre-market)
462    /// window — extended hours that precede the regular trading session.
463    ///
464    /// Alias for [`is_pre_market`](Self::is_pre_market). Always `false` for non-equity sessions.
465    #[deprecated(since = "2.2.0", note = "Use `is_pre_market` instead")]
466    pub fn is_pre_open(&self, utc_ms: u64) -> bool {
467        self.is_pre_market(utc_ms)
468    }
469
470    /// Fraction of the 24-hour UTC day **remaining** at `utc_ms`.
471    ///
472    /// Returns a value in `(0.0, 1.0]` — `1.0` exactly at midnight UTC,
473    /// approaching `0.0` just before the next midnight. The complement of
474    /// [`fraction_of_day_elapsed`](Self::fraction_of_day_elapsed).
475    pub fn day_fraction_remaining(&self, utc_ms: u64) -> f64 {
476        1.0 - self.fraction_of_day_elapsed(utc_ms)
477    }
478
479    /// Returns `true` if the market is in regular trading hours only
480    /// (`TradingStatus::Open`), not extended hours or closed.
481    pub fn is_regular_session(&self, utc_ms: u64) -> bool {
482        self.is_open(utc_ms)
483    }
484
485    /// Returns `true` if the current time is within the final 60 minutes of
486    /// the regular session.
487    pub fn is_last_trading_hour(&self, utc_ms: u64) -> bool {
488        self.remaining_session_ms(utc_ms).map_or(false, |r| r <= 3_600_000)
489    }
490
491    /// Returns `true` if the session is open and `utc_ms` is within
492    /// `margin_ms` of the end of the regular session.
493    ///
494    /// Returns `false` when outside the regular session (closed, extended,
495    /// etc.).  Uses the same remaining-time calculation as
496    /// [`remaining_session_ms`](Self::remaining_session_ms).
497    pub fn is_near_close(&self, utc_ms: u64, margin_ms: u64) -> bool {
498        self.remaining_session_ms(utc_ms).map_or(false, |r| r <= margin_ms)
499    }
500
501    /// Duration of the regular (non-extended) session in milliseconds.
502    ///
503    /// For `UsEquity` this is 6.5 hours (23,400,000 ms); for `Crypto` it
504    /// returns `u64::MAX` (always open); for `Forex` it returns the
505    /// standard weekly duration (120 hours = 432,000,000 ms).
506    pub fn open_duration_ms(&self) -> u64 {
507        self.session.session_duration_ms()
508    }
509
510    /// Returns `true` if within the first 30 minutes of the regular session.
511    ///
512    /// The opening range is a commonly watched period for establishing the
513    /// day's initial price range. Returns `false` outside the session.
514    pub fn is_opening_range(&self, utc_ms: u64) -> bool {
515        self.time_in_session_ms(utc_ms).map_or(false, |e| e < 30 * 60 * 1_000)
516    }
517
518    /// Returns `true` if the session is between 25 % and 75 % complete.
519    ///
520    /// Useful for identifying the "mid-session" consolidation period.
521    /// Returns `false` outside the session or when session progress is
522    /// unavailable (e.g. `Crypto`).
523    pub fn is_mid_session(&self, utc_ms: u64) -> bool {
524        self.session_progress(utc_ms).map_or(false, |p| p >= 0.25 && p <= 0.75)
525    }
526
527    /// Returns `true` if the session is in the first half (< 50%) of its duration.
528    ///
529    /// Returns `false` outside the session.
530    pub fn is_first_half(&self, utc_ms: u64) -> bool {
531        self.session_progress(utc_ms).map_or(false, |p| p < 0.5)
532    }
533
534    /// Returns which half of the trading session we are in: `1` for the first half,
535    /// `2` for the second half. Returns `0` if outside the session.
536    pub fn session_half(&self, utc_ms: u64) -> u8 {
537        self.session_progress(utc_ms).map_or(0, |p| if p < 0.5 { 1 } else { 2 })
538    }
539
540    /// Returns `true` if both `self` and `other` are simultaneously open at `utc_ms`.
541    ///
542    /// Useful for detecting session overlaps like the London/New York overlap (13:00–17:00 UTC).
543    pub fn overlaps_with(&self, other: &SessionAwareness, utc_ms: u64) -> bool {
544        self.is_open(utc_ms) && other.is_open(utc_ms)
545    }
546
547    /// Milliseconds elapsed since the session opened at `utc_ms`.
548    ///
549    /// Returns `0` if the session is not open.
550    pub fn open_ms(&self, utc_ms: u64) -> u64 {
551        self.time_in_session_ms(utc_ms).unwrap_or(0)
552    }
553
554    /// Session progress as a percentage `[0.0, 100.0]`.
555    ///
556    /// Returns `0.0` outside the session.
557    pub fn progress_pct(&self, utc_ms: u64) -> f64 {
558        self.session_progress(utc_ms).unwrap_or(0.0) * 100.0
559    }
560
561    /// Milliseconds remaining until the session closes at `utc_ms`.
562    ///
563    /// Returns `0` if the session is not open or already past close.
564    pub fn remaining_ms(&self, utc_ms: u64) -> u64 {
565        let elapsed = self.time_in_session_ms(utc_ms).unwrap_or(0);
566        let duration = self.session.session_duration_ms();
567        if duration == u64::MAX { return u64::MAX; }
568        duration.saturating_sub(elapsed)
569    }
570
571    /// Returns `true` if the session is in the first 25% of its duration.
572    ///
573    /// Returns `false` outside the session.
574    pub fn is_first_quarter(&self, utc_ms: u64) -> bool {
575        self.session_progress(utc_ms).map_or(false, |p| p < 0.25)
576    }
577
578    /// Returns `true` if the session is in the last 25% of its duration.
579    ///
580    /// Returns `false` outside the session.
581    pub fn is_last_quarter(&self, utc_ms: u64) -> bool {
582        self.session_progress(utc_ms).map_or(false, |p| p > 0.75)
583    }
584
585    /// Minutes elapsed since the session opened.
586    ///
587    /// Returns `0.0` if the session is not open.
588    pub fn minutes_elapsed(&self, utc_ms: u64) -> f64 {
589        self.time_in_session_ms(utc_ms).unwrap_or(0) as f64 / 60_000.0
590    }
591
592    /// Returns `true` if within the last 60 minutes of the regular session (the "power hour").
593    ///
594    /// Returns `false` outside the session.
595    pub fn is_power_hour(&self, utc_ms: u64) -> bool {
596        self.session_progress(utc_ms).map_or(false, |p| p > (5.5 / 6.5))
597    }
598
599    /// Returns `true` if the session is overnight: the market is closed
600    /// (or extended-hours-only) and the current UTC time falls between
601    /// 20:00 ET (after-hours end) and 04:00 ET (pre-market start) on a weekday.
602    ///
603    /// Always `false` for `Crypto` (never closes) and `Forex` (uses its own
604    /// schedule).
605    pub fn is_overnight(&self, utc_ms: u64) -> bool {
606        if self.session != crate::session::MarketSession::UsEquity {
607            return false;
608        }
609        !self.is_open(utc_ms) && !self.is_extended_hours(utc_ms) && !Self::is_weekend(utc_ms)
610    }
611
612    /// Minutes until the next regular session open, as `f64`.
613    ///
614    /// Returns `0.0` when the session is already open. Uses
615    /// [`time_until_open_ms`](Self::time_until_open_ms) for the underlying
616    /// calculation.
617    pub fn minutes_to_next_open(&self, utc_ms: u64) -> f64 {
618        self.time_until_open_ms(utc_ms) as f64 / 60_000.0
619    }
620
621    /// How far through the current session as a percentage (0.0–100.0).
622    ///
623    /// Alias for [`progress_pct`](Self::progress_pct). Returns `0.0` if the session is not currently open.
624    #[deprecated(since = "2.2.0", note = "Use `progress_pct` instead")]
625    pub fn session_progress_pct(&self, utc_ms: u64) -> f64 {
626        self.progress_pct(utc_ms)
627    }
628
629    /// Returns `true` if the session is open and within the last 60 seconds of
630    /// the regular trading session.
631    pub fn is_last_minute(&self, utc_ms: u64) -> bool {
632        self.remaining_session_ms(utc_ms).map_or(false, |r| r <= 60_000)
633    }
634
635    /// Returns the week of the month (1–5) for `date`.
636    ///
637    /// Week 1 contains day 1, week 2 contains day 8, etc.
638    pub fn week_of_month(date: NaiveDate) -> u32 {
639        (date.day() - 1) / 7 + 1
640    }
641
642    /// Returns the weekday name of `date` as a static string.
643    ///
644    /// Returns one of `"Monday"`, `"Tuesday"`, `"Wednesday"`, `"Thursday"`,
645    /// `"Friday"`, `"Saturday"`, or `"Sunday"`.
646    pub fn day_of_week_name(date: NaiveDate) -> &'static str {
647        match date.weekday() {
648            Weekday::Mon => "Monday",
649            Weekday::Tue => "Tuesday",
650            Weekday::Wed => "Wednesday",
651            Weekday::Thu => "Thursday",
652            Weekday::Fri => "Friday",
653            Weekday::Sat => "Saturday",
654            Weekday::Sun => "Sunday",
655        }
656    }
657
658    /// Returns `true` if `date` falls in the final week of the month (day ≥ 22).
659    ///
660    /// Weekly equity options expire on Fridays; monthly contracts expire the
661    /// third Friday. The final calendar week of the month covers both cases
662    /// and is often associated with elevated volatility and rebalancing.
663    pub fn is_expiry_week(date: NaiveDate) -> bool {
664        date.day() >= 22
665    }
666
667    /// Human-readable name of the configured market session.
668    pub fn session_name(&self) -> &'static str {
669        match self.session {
670            MarketSession::UsEquity => "US Equity",
671            MarketSession::Crypto => "Crypto",
672            MarketSession::Forex => "Forex",
673        }
674    }
675
676    /// Returns `true` if `date` falls in a typical US earnings season month
677    /// (January, April, July, or October).
678    ///
679    /// These months see heavy corporate earnings reports, increasing market volatility.
680    pub fn is_earnings_season(date: NaiveDate) -> bool {
681        matches!(date.month(), 1 | 4 | 7 | 10)
682    }
683
684    /// Returns `true` if `utc_ms` falls within the midday 12:00–13:00 ET quiet period
685    /// (3 hours into the 6.5-hour US equity session).
686    ///
687    /// Always returns `false` for non-US-Equity sessions.
688    pub fn is_lunch_hour(&self, utc_ms: u64) -> bool {
689        if self.session != MarketSession::UsEquity {
690            return false;
691        }
692        self.time_in_session_ms(utc_ms).map_or(false, |e| e >= 150 * 60 * 1_000 && e < 210 * 60 * 1_000)
693    }
694
695    /// Returns `true` if `date` is a triple witching day (third Friday of March, June,
696    /// September, or December) — the quarterly expiration of index futures, index options,
697    /// and single-stock options.
698    pub fn is_triple_witching(date: NaiveDate) -> bool {
699        let month = date.month();
700        if !matches!(month, 3 | 6 | 9 | 12) {
701            return false;
702        }
703        if date.weekday() != Weekday::Fri {
704            return false;
705        }
706        // Third Friday: day is in [15, 21]
707        let day = date.day();
708        day >= 15 && day <= 21
709    }
710
711    /// Count of weekdays (Mon–Fri) between `from` and `to`, inclusive.
712    ///
713    /// Returns 0 if `from > to`. Does not account for US market holidays.
714    pub fn trading_days_elapsed(from: NaiveDate, to: NaiveDate) -> u32 {
715        if from > to {
716            return 0;
717        }
718        let total_days = (to - from).num_days() + 1;
719        let mut weekdays = 0u32;
720        let mut d = from;
721        for _ in 0..total_days {
722            if !matches!(d.weekday(), Weekday::Sat | Weekday::Sun) {
723                weekdays += 1;
724            }
725            if let Some(next) = d.succ_opt() {
726                d = next;
727            }
728        }
729        weekdays
730    }
731
732    /// Fraction of the session remaining: `1.0 - session_progress`.
733    ///
734    /// Returns `None` if the session is not open.
735    pub fn fraction_remaining(&self, utc_ms: u64) -> Option<f64> {
736        self.session_progress(utc_ms).map(|p| 1.0 - p)
737    }
738
739    /// Minutes elapsed since the most recent session close.
740    ///
741    /// Returns `0.0` if the session is currently open or the last close
742    /// time cannot be determined (e.g., before any session has ever closed).
743    pub fn minutes_since_close(&self, utc_ms: u64) -> f64 {
744        if self.is_open(utc_ms) {
745            return 0.0;
746        }
747        (self.time_until_open_ms(utc_ms) as f64) / 60_000.0
748    }
749
750    /// Returns `true` if `utc_ms` falls within the first 60 seconds of the
751    /// regular trading session (the "opening bell" minute).
752    pub fn is_opening_bell_minute(&self, utc_ms: u64) -> bool {
753        self.time_in_session_ms(utc_ms).map_or(false, |e| e <= 60_000)
754    }
755
756    /// Returns `true` if `utc_ms` falls within the final 60 seconds of the session.
757    ///
758    /// Always returns `false` for non-US-Equity sessions or when the session is not open.
759    pub fn is_closing_bell_minute(&self, utc_ms: u64) -> bool {
760        if self.session != MarketSession::UsEquity {
761            return false;
762        }
763        const SESSION_LENGTH_MS: u64 = 6 * 3_600_000 + 30 * 60_000; // 6.5h
764        self.time_in_session_ms(utc_ms).map_or(false, |e| e + 60_000 >= SESSION_LENGTH_MS)
765    }
766
767    /// Returns `true` if `date` is the day immediately before or after a major
768    /// US market holiday (Christmas, New Year's Day, Independence Day, Thanksgiving
769    /// Friday, or Labor Day). These adjacent days often see reduced liquidity.
770    ///
771    /// Uses a fixed-rule approximation: does not account for observed holidays
772    /// that shift when the holiday falls on a weekend.
773    pub fn is_market_holiday_adjacent(date: NaiveDate) -> bool {
774        let month = date.month();
775        let day = date.day();
776        // Dec 24 (Christmas Eve) or Dec 26 (day after Christmas)
777        if month == 12 && (day == 24 || day == 26) {
778            return true;
779        }
780        // Dec 31 (New Year's Eve) or Jan 2 (day after New Year's)
781        if (month == 12 && day == 31) || (month == 1 && day == 2) {
782            return true;
783        }
784        // Jul 3 (day before Independence Day) or Jul 5 (day after)
785        if month == 7 && (day == 3 || day == 5) {
786            return true;
787        }
788        // Black Friday (day after Thanksgiving) — 4th Friday of November
789        if month == 11 && date.weekday() == Weekday::Fri {
790            let d = day;
791            if d >= 23 && d <= 29 {
792                return true;
793            }
794        }
795        false
796    }
797
798    /// Returns `true` if `date` falls within an approximate FOMC blackout window.
799    ///
800    /// The Fed's blackout rule prohibits public commentary in the 10 calendar days
801    /// before each FOMC decision. FOMC meetings are scheduled roughly 8 times per
802    /// year; this heuristic marks days 18–31 of odd months (Jan, Mar, May, Jul,
803    /// Sep, Nov) as blackout candidates — a conservative approximation.
804    pub fn is_fomc_blackout_window(date: NaiveDate) -> bool {
805        let day = date.day();
806        // Meetings land in odd-numbered months; blackout starts ~10 days before
807        // the typical mid/late-month decision date.
808        matches!(date.month(), 1 | 3 | 5 | 7 | 9 | 11) && day >= 18
809    }
810
811    fn next_forex_close_ms(&self, utc_ms: u64) -> u64 {
812        if self.forex_status(utc_ms) == TradingStatus::Closed {
813            return utc_ms;
814        }
815        // Forex closes Friday 22:00 UTC.
816        let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; // 0=Sun..6=Sat
817        let day_ms = utc_ms % (24 * 3600 * 1000);
818        let start_of_day = utc_ms - day_ms;
819        let hour_22_ms: u64 = 22 * 3600 * 1000;
820
821        // Days until next Friday
822        let days_to_friday: u64 = match day_of_week {
823            0 => 5, // Sun → Fri
824            1 => 4, // Mon → Fri
825            2 => 3, // Tue → Fri
826            3 => 2, // Wed → Fri
827            4 => 1, // Thu → Fri
828            5 => 0, // Fri (before 22:00, so close is today)
829            _ => 6, // Sat shouldn't happen (already closed)
830        };
831        start_of_day + days_to_friday * 24 * 3600 * 1000 + hour_22_ms
832    }
833
834    fn next_us_equity_close_ms(&self, utc_ms: u64) -> u64 {
835        if self.us_equity_status(utc_ms) == TradingStatus::Closed {
836            return utc_ms;
837        }
838        // Market is Open or Extended. Next full close is today at 20:00 ET.
839        // 20:00 ET = 00:00 UTC next calendar day (EDT) or 01:00 UTC next day (EST).
840        let secs = (utc_ms / 1000) as i64;
841        let dt = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
842        let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
843        let et_dt = dt + Duration::seconds(et_offset_secs);
844        let et_date = et_dt.date_naive();
845
846        // The UTC calendar date of 20:00 ET is the day after the ET date.
847        let utc_date = et_date + Duration::days(1);
848        let close_hour_utc: u32 = if is_us_dst(utc_ms) { 0 } else { 1 };
849        let approx_ms = date_to_utc_ms(utc_date, close_hour_utc, 0);
850        let corrected_hour: u32 = if is_us_dst(approx_ms) { 0 } else { 1 };
851        date_to_utc_ms(utc_date, corrected_hour, 0)
852    }
853
854    fn next_forex_open_ms(&self, utc_ms: u64) -> u64 {
855        if self.forex_status(utc_ms) == TradingStatus::Open {
856            return utc_ms;
857        }
858        // Forex is closed on Saturday, Sunday before 22:00, or Friday >= 22:00.
859        // The next open is always Sunday 22:00 UTC.
860        let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; // 0=Sun, ..., 6=Sat
861        let day_ms = utc_ms % (24 * 3600 * 1000);
862        let start_of_day = utc_ms - day_ms;
863        let hour_22_ms: u64 = 22 * 3600 * 1000;
864
865        let days_to_sunday: u64 = match day_of_week {
866            0 => 0, // Sunday before 22:00 → today at 22:00
867            5 => 2, // Friday after 22:00 → 2 days to Sunday
868            6 => 1, // Saturday → 1 day to Sunday
869            _ => 0, // other days shouldn't happen (forex is open)
870        };
871        start_of_day + days_to_sunday * 24 * 3600 * 1000 + hour_22_ms
872    }
873
874    fn next_us_equity_open_ms(&self, utc_ms: u64) -> u64 {
875        if self.us_equity_status(utc_ms) == TradingStatus::Open {
876            return utc_ms;
877        }
878        let secs = (utc_ms / 1000) as i64;
879        let dt = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
880        let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
881        let et_dt = dt + Duration::seconds(et_offset_secs);
882
883        let dow = et_dt.weekday();
884        let et_time_secs = et_dt.num_seconds_from_midnight() as u64;
885        let open_s: u64 = 9 * 3600 + 30 * 60; // 09:30 ET
886
887        // Days to advance in ET to reach the next trading-day open.
888        let days_ahead: i64 = match dow {
889            Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu => {
890                if et_time_secs < open_s {
891                    0 // Before today's open
892                } else {
893                    1 // After today's open: next weekday
894                }
895            }
896            Weekday::Fri => {
897                if et_time_secs < open_s {
898                    0 // Before today's open
899                } else {
900                    3 // After Friday: skip to Monday
901                }
902            }
903            Weekday::Sat => 2, // Skip to Monday
904            Weekday::Sun => 1, // Skip to Monday
905        };
906
907        let et_date = et_dt.date_naive();
908        let mut target_date = et_date + Duration::days(days_ahead);
909
910        // Skip over weekends and holidays to find the next trading day.
911        loop {
912            let wd = target_date.weekday();
913            if wd == Weekday::Sat {
914                target_date += Duration::days(2);
915                continue;
916            }
917            if wd == Weekday::Sun {
918                target_date += Duration::days(1);
919                continue;
920            }
921            if is_us_market_holiday(target_date) {
922                target_date += Duration::days(1);
923                continue;
924            }
925            break;
926        }
927
928        // Approximate UTC hour for 9:30 ET, then correct for target-date DST.
929        let open_hour_utc: u32 = if is_us_dst(utc_ms) { 13 } else { 14 };
930        let approx_ms = date_to_utc_ms(target_date, open_hour_utc, 30);
931        let open_hour_utc_corrected: u32 = if is_us_dst(approx_ms) { 13 } else { 14 };
932        date_to_utc_ms(target_date, open_hour_utc_corrected, 30)
933    }
934
935    fn us_equity_status(&self, utc_ms: u64) -> TradingStatus {
936        let secs = (utc_ms / 1000) as i64;
937        let dt = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
938
939        // Determine ET offset: EDT = UTC-4 during DST, EST = UTC-5 otherwise.
940        let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
941        let et_dt = dt + Duration::seconds(et_offset_secs);
942
943        // Closed on weekends.
944        let dow = et_dt.weekday();
945        if dow == Weekday::Sat || dow == Weekday::Sun {
946            return TradingStatus::Closed;
947        }
948
949        // Closed on US market holidays.
950        if is_us_market_holiday(et_dt.date_naive()) {
951            return TradingStatus::Closed;
952        }
953
954        let et_time_secs = et_dt.num_seconds_from_midnight() as u64;
955        let open_s = (9 * 3600 + 30 * 60) as u64; // 09:30 ET
956        let close_s = (16 * 3600) as u64; // 16:00 ET
957        let pre_s = (4 * 3600) as u64; // 04:00 ET
958        let post_s = (20 * 3600) as u64; // 20:00 ET
959
960        if et_time_secs >= open_s && et_time_secs < close_s {
961            TradingStatus::Open
962        } else if (et_time_secs >= pre_s && et_time_secs < open_s)
963            || (et_time_secs >= close_s && et_time_secs < post_s)
964        {
965            TradingStatus::Extended
966        } else {
967            TradingStatus::Closed
968        }
969    }
970
971    fn forex_status(&self, utc_ms: u64) -> TradingStatus {
972        // Forex: open Sunday 22:00 UTC – Friday 22:00 UTC (approximately)
973        let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; // 0=Sun, 1=Mon, ..., 6=Sat
974        let day_ms = utc_ms % (24 * 3600 * 1000);
975        let hour_22_ms = 22 * 3600 * 1000;
976
977        // Fully closed: Saturday entire day, Sunday before 22:00
978        if day_of_week == 6 {
979            return TradingStatus::Closed;
980        }
981        if day_of_week == 0 && day_ms < hour_22_ms {
982            return TradingStatus::Closed;
983        }
984        // Friday after 22:00 UTC — also closed
985        if day_of_week == 5 && day_ms >= hour_22_ms {
986            return TradingStatus::Closed;
987        }
988        TradingStatus::Open
989    }
990}
991
992/// Return `true` if `utc_ms` falls within US Daylight Saving Time.
993///
994/// US DST rules (since 2007):
995/// - **Starts**: second Sunday of March at 02:00 local time (07:00 UTC in EST).
996/// - **Ends**: first Sunday of November at 02:00 local time (06:00 UTC in EDT).
997fn is_us_dst(utc_ms: u64) -> bool {
998    let secs = (utc_ms / 1000) as i64;
999    let dt = match Utc.timestamp_opt(secs, 0).single() {
1000        Some(t) => t,
1001        None => return false,
1002    };
1003    let year = dt.year();
1004
1005    // DST starts: 2nd Sunday of March at 02:00 EST = 07:00 UTC.
1006    let dst_start_date = nth_weekday_of_month(year, 3, Weekday::Sun, 2);
1007    let dst_start_utc_ms = date_to_utc_ms(dst_start_date, 7, 0);
1008
1009    // DST ends: 1st Sunday of November at 02:00 EDT = 06:00 UTC.
1010    let dst_end_date = nth_weekday_of_month(year, 11, Weekday::Sun, 1);
1011    let dst_end_utc_ms = date_to_utc_ms(dst_end_date, 6, 0);
1012
1013    utc_ms >= dst_start_utc_ms && utc_ms < dst_end_utc_ms
1014}
1015
1016/// Return the date of the N-th occurrence (1-indexed) of `weekday` in the given month/year.
1017fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> NaiveDate {
1018    let first = NaiveDate::from_ymd_opt(year, month, 1)
1019        .unwrap_or_else(|| NaiveDate::from_ymd_opt(year, 1, 1).unwrap());
1020    let first_wd = first.weekday();
1021    let days_ahead = (weekday.num_days_from_monday() as i32
1022        - first_wd.num_days_from_monday() as i32)
1023        .rem_euclid(7);
1024    let first_occurrence = first + Duration::days(days_ahead as i64);
1025    first_occurrence + Duration::weeks((n - 1) as i64)
1026}
1027
1028/// Convert a `NaiveDate` plus hour/minute to UTC milliseconds.
1029fn date_to_utc_ms(date: NaiveDate, hour: u32, minute: u32) -> u64 {
1030    let naive_dt = date
1031        .and_hms_opt(hour, minute, 0)
1032        .unwrap_or_else(|| date.and_hms_opt(0, 0, 0).unwrap());
1033    let utc_dt = Utc.from_utc_datetime(&naive_dt);
1034    (utc_dt.timestamp() as u64) * 1000
1035}
1036
1037/// Returns `true` if `date` (in ET) is a US equity market holiday (NYSE/NASDAQ closure).
1038///
1039/// Covers all fixed-date and floating holidays observed by US exchanges:
1040/// - **Fixed**: New Year's Day, Juneteenth (since 2022), Independence Day, Christmas Day.
1041/// - **Floating**: MLK Day, Presidents Day, Good Friday, Memorial Day, Labor Day, Thanksgiving.
1042///
1043/// Weekend-observation rules apply: Saturday holidays observe on the prior Friday,
1044/// Sunday holidays observe on the following Monday.
1045pub fn is_us_market_holiday(date: NaiveDate) -> bool {
1046    let year = date.year();
1047
1048    // Helper: apply weekend-observation rule.
1049    let observe = |d: NaiveDate| -> NaiveDate {
1050        match d.weekday() {
1051            Weekday::Sat => d - Duration::days(1),
1052            Weekday::Sun => d + Duration::days(1),
1053            _ => d,
1054        }
1055    };
1056
1057    let make_date = |y: i32, m: u32, d: u32| {
1058        NaiveDate::from_ymd_opt(y, m, d).unwrap_or_else(|| NaiveDate::from_ymd_opt(y, 1, 1).unwrap())
1059    };
1060
1061    // New Year's Day: January 1 (observed).
1062    if date == observe(make_date(year, 1, 1)) {
1063        return true;
1064    }
1065    // MLK Day: 3rd Monday of January.
1066    if date == nth_weekday_of_month(year, 1, Weekday::Mon, 3) {
1067        return true;
1068    }
1069    // Presidents Day: 3rd Monday of February.
1070    if date == nth_weekday_of_month(year, 2, Weekday::Mon, 3) {
1071        return true;
1072    }
1073    // Good Friday: 2 days before Easter Sunday.
1074    if date == good_friday(year) {
1075        return true;
1076    }
1077    // Memorial Day: last Monday of May.
1078    if date.month() == 5 && date.weekday() == Weekday::Mon {
1079        let next_monday = date + Duration::days(7);
1080        if next_monday.month() != 5 {
1081            return true;
1082        }
1083    }
1084    // Juneteenth: June 19 (observed, since 2022).
1085    if year >= 2022 && date == observe(make_date(year, 6, 19)) {
1086        return true;
1087    }
1088    // Independence Day: July 4 (observed).
1089    if date == observe(make_date(year, 7, 4)) {
1090        return true;
1091    }
1092    // Labor Day: 1st Monday of September.
1093    if date == nth_weekday_of_month(year, 9, Weekday::Mon, 1) {
1094        return true;
1095    }
1096    // Thanksgiving: 4th Thursday of November.
1097    if date == nth_weekday_of_month(year, 11, Weekday::Thu, 4) {
1098        return true;
1099    }
1100    // Christmas Day: December 25 (observed).
1101    if date == observe(make_date(year, 12, 25)) {
1102        return true;
1103    }
1104    false
1105}
1106
1107/// Compute the date of Good Friday for a given year (2 days before Easter Sunday).
1108fn good_friday(year: i32) -> NaiveDate {
1109    easter_sunday(year) - Duration::days(2)
1110}
1111
1112/// Compute Easter Sunday using the Anonymous Gregorian algorithm.
1113fn easter_sunday(year: i32) -> NaiveDate {
1114    let a = year % 19;
1115    let b = year / 100;
1116    let c = year % 100;
1117    let d = b / 4;
1118    let e = b % 4;
1119    let f = (b + 8) / 25;
1120    let g = (b - f + 1) / 3;
1121    let h = (19 * a + b - d - g + 15) % 30;
1122    let i = c / 4;
1123    let k = c % 4;
1124    let l = (32 + 2 * e + 2 * i - h - k) % 7;
1125    let m = (a + 11 * h + 22 * l) / 451;
1126    let month = (h + l - 7 * m + 114) / 31;
1127    let day = ((h + l - 7 * m + 114) % 31) + 1;
1128    NaiveDate::from_ymd_opt(year, month as u32, day as u32)
1129        .unwrap_or_else(|| NaiveDate::from_ymd_opt(year, 1, 1).unwrap())
1130}
1131
1132/// Count of US equity trading days (non-holiday weekdays) in the UTC millisecond range
1133/// `[start_ms, end_ms)`.
1134///
1135/// Uses the same holiday calendar as [`is_us_market_holiday`].
1136/// Returns `0` if `end_ms <= start_ms`.
1137pub fn trading_day_count(start_ms: u64, end_ms: u64) -> usize {
1138    use chrono::{Datelike, NaiveDate, TimeZone, Utc, Weekday};
1139    if end_ms <= start_ms {
1140        return 0;
1141    }
1142    let ms_to_naive = |ms: u64| {
1143        Utc.timestamp_opt((ms / 1000) as i64, 0)
1144            .single()
1145            .map(|dt| dt.date_naive())
1146            .unwrap_or_else(|| NaiveDate::from_ymd_opt(1970, 1, 1).unwrap())
1147    };
1148    let start_date = ms_to_naive(start_ms);
1149    let end_date = ms_to_naive(end_ms);
1150    let mut count = 0usize;
1151    let mut day = start_date;
1152    while day < end_date {
1153        let wd = day.weekday();
1154        if wd != Weekday::Sat && wd != Weekday::Sun && !is_us_market_holiday(day) {
1155            count += 1;
1156        }
1157        day = day.succ_opt().unwrap_or(day);
1158    }
1159    count
1160}
1161
1162/// Convenience: check if a session is currently tradeable.
1163pub fn is_tradeable(session: MarketSession, utc_ms: u64) -> Result<bool, StreamError> {
1164    let sa = SessionAwareness::new(session);
1165    let status = sa.status(utc_ms)?;
1166    Ok(status == TradingStatus::Open || status == TradingStatus::Extended)
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171    use super::*;
1172
1173    // Reference Monday 2024-01-08 14:30 UTC = 09:30 ET (market open, EST=UTC-5)
1174    const MON_OPEN_UTC_MS: u64 = 1704724200000;
1175    // Monday 21:00 UTC = 16:00 ET (market close, extended hours start)
1176    const MON_CLOSE_UTC_MS: u64 = 1704747600000;
1177    // Saturday 2024-01-13 12:00 UTC = 07:00 EST — Saturday in both UTC and ET
1178    const SAT_UTC_MS: u64 = 1705147200000;
1179    // Sunday 2024-01-07 10:00 UTC (before 22:00 UTC, forex closed)
1180    const SUN_BEFORE_UTC_MS: u64 = 1704621600000;
1181
1182    // Summer: Monday 2024-07-08 13:30 UTC = 09:30 EDT (UTC-4), market open
1183    const MON_SUMMER_OPEN_UTC_MS: u64 = 1720445400000;
1184
1185    fn sa(session: MarketSession) -> SessionAwareness {
1186        SessionAwareness::new(session)
1187    }
1188
1189    #[test]
1190    fn test_crypto_always_open() {
1191        let sa = sa(MarketSession::Crypto);
1192        assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
1193        assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Open);
1194        assert_eq!(sa.status(0).unwrap(), TradingStatus::Open);
1195    }
1196
1197    #[test]
1198    fn test_us_equity_open_during_market_hours_est() {
1199        let sa = sa(MarketSession::UsEquity);
1200        // 14:30 UTC = 09:30 EST (UTC-5) — January, no DST
1201        assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
1202    }
1203
1204    #[test]
1205    fn test_us_equity_open_during_market_hours_edt() {
1206        let sa = sa(MarketSession::UsEquity);
1207        // 13:30 UTC = 09:30 EDT (UTC-4) — July, DST active
1208        assert_eq!(
1209            sa.status(MON_SUMMER_OPEN_UTC_MS).unwrap(),
1210            TradingStatus::Open
1211        );
1212    }
1213
1214    #[test]
1215    fn test_us_equity_closed_after_hours() {
1216        let sa = sa(MarketSession::UsEquity);
1217        // 21:00 UTC = 16:00 ET = market close, after-hours starts
1218        let status = sa.status(MON_CLOSE_UTC_MS).unwrap();
1219        assert!(status == TradingStatus::Extended || status == TradingStatus::Closed);
1220    }
1221
1222    #[test]
1223    fn test_us_equity_closed_on_saturday() {
1224        let sa = sa(MarketSession::UsEquity);
1225        assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Closed);
1226    }
1227
1228    #[test]
1229    fn test_us_equity_premarket_extended() {
1230        let sa = sa(MarketSession::UsEquity);
1231        // Monday 09:00 UTC = 04:00 ET (pre-market, EST=UTC-5)
1232        let pre_ms: u64 = 1704704400000;
1233        let status = sa.status(pre_ms).unwrap();
1234        assert!(status == TradingStatus::Extended || status == TradingStatus::Open);
1235    }
1236
1237    #[test]
1238    fn test_dst_transition_march() {
1239        // 2024 DST starts: March 10 at 07:00 UTC (2:00 AM EST → 3:00 AM EDT)
1240        // Just before: 06:59 UTC → EST (UTC-5) → 01:59 ET → Closed
1241        let just_before_dst_ms = 1710053940000_u64; // 2024-03-10 06:59 UTC
1242        // Just after: 07:01 UTC → EDT (UTC-4) → 03:01 ET → Closed (pre-market)
1243        let just_after_dst_ms = 1710054060000_u64; // 2024-03-10 07:01 UTC
1244        assert!(!is_us_dst(just_before_dst_ms));
1245        assert!(is_us_dst(just_after_dst_ms));
1246    }
1247
1248    #[test]
1249    fn test_dst_transition_november() {
1250        // 2024 DST ends: November 3 at 06:00 UTC (2:00 AM EDT → 1:00 AM EST)
1251        // Just before: 05:59 UTC → DST still active
1252        let just_before_end_ms = 1730613540000_u64; // 2024-11-03 05:59 UTC
1253        // Just after: 06:01 UTC → DST ended
1254        let just_after_end_ms = 1730613660000_u64; // 2024-11-03 06:01 UTC
1255        assert!(is_us_dst(just_before_end_ms));
1256        assert!(!is_us_dst(just_after_end_ms));
1257    }
1258
1259    #[test]
1260    fn test_forex_open_on_monday() {
1261        let sa = sa(MarketSession::Forex);
1262        assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
1263    }
1264
1265    #[test]
1266    fn test_forex_closed_on_saturday() {
1267        let sa = sa(MarketSession::Forex);
1268        assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Closed);
1269    }
1270
1271    #[test]
1272    fn test_forex_closed_sunday_before_22_utc() {
1273        let sa = sa(MarketSession::Forex);
1274        assert_eq!(sa.status(SUN_BEFORE_UTC_MS).unwrap(), TradingStatus::Closed);
1275    }
1276
1277    #[test]
1278    fn test_is_tradeable_crypto_always_true() {
1279        assert!(is_tradeable(MarketSession::Crypto, SAT_UTC_MS).unwrap());
1280    }
1281
1282    #[test]
1283    fn test_is_tradeable_equity_open() {
1284        assert!(is_tradeable(MarketSession::UsEquity, MON_OPEN_UTC_MS).unwrap());
1285    }
1286
1287    #[test]
1288    fn test_is_tradeable_equity_weekend_false() {
1289        assert!(!is_tradeable(MarketSession::UsEquity, SAT_UTC_MS).unwrap());
1290    }
1291
1292    #[test]
1293    fn test_session_accessor() {
1294        let sa = sa(MarketSession::Crypto);
1295        assert_eq!(sa.session(), MarketSession::Crypto);
1296    }
1297
1298    #[test]
1299    fn test_market_session_equality() {
1300        assert_eq!(MarketSession::Crypto, MarketSession::Crypto);
1301        assert_ne!(MarketSession::Crypto, MarketSession::Forex);
1302    }
1303
1304    #[test]
1305    fn test_trading_status_equality() {
1306        assert_eq!(TradingStatus::Open, TradingStatus::Open);
1307        assert_ne!(TradingStatus::Open, TradingStatus::Closed);
1308    }
1309
1310    #[test]
1311    fn test_nth_weekday_of_month_second_sunday_march_2024() {
1312        // Second Sunday of March 2024 = March 10
1313        let date = nth_weekday_of_month(2024, 3, Weekday::Sun, 2);
1314        assert_eq!(date.month(), 3);
1315        assert_eq!(date.day(), 10);
1316    }
1317
1318    #[test]
1319    fn test_nth_weekday_of_month_first_sunday_november_2024() {
1320        // First Sunday of November 2024 = November 3
1321        let date = nth_weekday_of_month(2024, 11, Weekday::Sun, 1);
1322        assert_eq!(date.month(), 11);
1323        assert_eq!(date.day(), 3);
1324    }
1325
1326    // ── next_open_ms ──────────────────────────────────────────────────────────
1327
1328    #[test]
1329    fn test_next_open_crypto_is_always_now() {
1330        let sa = sa(MarketSession::Crypto);
1331        assert_eq!(sa.next_open_ms(SAT_UTC_MS), SAT_UTC_MS);
1332        assert_eq!(sa.next_open_ms(MON_OPEN_UTC_MS), MON_OPEN_UTC_MS);
1333    }
1334
1335    #[test]
1336    fn test_next_open_equity_already_open_returns_same() {
1337        // MON_OPEN_UTC_MS = Monday 14:30 UTC = 09:30 ET (market is open)
1338        let sa = sa(MarketSession::UsEquity);
1339        let next = sa.next_open_ms(MON_OPEN_UTC_MS);
1340        assert_eq!(next, MON_OPEN_UTC_MS);
1341    }
1342
1343    #[test]
1344    fn test_next_open_equity_saturday_returns_monday_open() {
1345        // SAT_UTC_MS = 2024-01-13 Saturday 12:00 UTC; market closed.
1346        // Mon 2024-01-15 is MLK Day (holiday), so next open = Tue 2024-01-16 14:30 UTC (EST).
1347        let sa = sa(MarketSession::UsEquity);
1348        let next = sa.next_open_ms(SAT_UTC_MS);
1349        // The result should be after SAT_UTC_MS and should be Open when checked.
1350        assert!(next > SAT_UTC_MS, "next open must be after Saturday");
1351        assert_eq!(
1352            sa.status(next).unwrap(),
1353            TradingStatus::Open,
1354            "next_open_ms must return a time when market is Open"
1355        );
1356    }
1357
1358    #[test]
1359    fn test_next_open_equity_sunday_returns_monday_open() {
1360        // Sunday 10:00 UTC → next open is Monday 9:30 ET
1361        let sa = sa(MarketSession::UsEquity);
1362        let next = sa.next_open_ms(SUN_BEFORE_UTC_MS);
1363        assert!(next > SUN_BEFORE_UTC_MS);
1364        assert_eq!(sa.status(next).unwrap(), TradingStatus::Open);
1365    }
1366
1367    #[test]
1368    fn test_next_open_forex_already_open_returns_same() {
1369        let sa = sa(MarketSession::Forex);
1370        assert_eq!(sa.next_open_ms(MON_OPEN_UTC_MS), MON_OPEN_UTC_MS);
1371    }
1372
1373    #[test]
1374    fn test_next_open_forex_saturday_returns_sunday_22_utc() {
1375        // Saturday 12:00 UTC → next open is Sunday 22:00 UTC (1 day later).
1376        let sa = sa(MarketSession::Forex);
1377        let next = sa.next_open_ms(SAT_UTC_MS);
1378        assert!(next > SAT_UTC_MS);
1379        assert_eq!(sa.status(next).unwrap(), TradingStatus::Open);
1380        // Should be exactly Sunday 22:00 UTC.
1381        let expected_hour_ms = 22 * 3600 * 1000;
1382        assert_eq!(next % (24 * 3600 * 1000), expected_hour_ms);
1383    }
1384
1385    #[test]
1386    fn test_next_open_forex_sunday_before_22_returns_same_day_22() {
1387        // SUN_BEFORE_UTC_MS = Sunday 10:00 UTC → next open is Sunday 22:00 UTC.
1388        let sa = sa(MarketSession::Forex);
1389        let next = sa.next_open_ms(SUN_BEFORE_UTC_MS);
1390        let day_ms = SUN_BEFORE_UTC_MS - (SUN_BEFORE_UTC_MS % (24 * 3600 * 1000));
1391        let expected = day_ms + 22 * 3600 * 1000;
1392        assert_eq!(next, expected);
1393    }
1394
1395    // ── Holiday calendar ──────────────────────────────────────────────────────
1396
1397    #[test]
1398    fn test_holiday_new_years_day_2024() {
1399        // 2024-01-01 is a Monday — New Year's Day, market closed.
1400        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1401        assert!(is_us_market_holiday(date), "New Year's Day should be a holiday");
1402    }
1403
1404    #[test]
1405    fn test_holiday_new_years_observed_when_on_sunday() {
1406        // 2023-01-01 is a Sunday — observed Monday 2023-01-02.
1407        let observed = NaiveDate::from_ymd_opt(2023, 1, 2).unwrap();
1408        assert!(is_us_market_holiday(observed), "Observed New Year's should be a holiday");
1409        let actual = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1410        assert!(!is_us_market_holiday(actual), "Sunday itself is not the observed holiday");
1411    }
1412
1413    #[test]
1414    fn test_holiday_mlk_day_2024() {
1415        // 2024-01-15 = 3rd Monday of January.
1416        let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
1417        assert!(is_us_market_holiday(date), "MLK Day should be a holiday");
1418    }
1419
1420    #[test]
1421    fn test_holiday_good_friday_2024() {
1422        // Easter 2024 = March 31; Good Friday = March 29.
1423        let date = NaiveDate::from_ymd_opt(2024, 3, 29).unwrap();
1424        assert!(is_us_market_holiday(date), "Good Friday 2024 should be a holiday");
1425    }
1426
1427    #[test]
1428    fn test_holiday_memorial_day_2024() {
1429        // Last Monday of May 2024 = May 27.
1430        let date = NaiveDate::from_ymd_opt(2024, 5, 27).unwrap();
1431        assert!(is_us_market_holiday(date), "Memorial Day should be a holiday");
1432    }
1433
1434    #[test]
1435    fn test_holiday_independence_day_2024() {
1436        // July 4, 2024 is a Thursday.
1437        let date = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
1438        assert!(is_us_market_holiday(date), "Independence Day should be a holiday");
1439    }
1440
1441    #[test]
1442    fn test_holiday_labor_day_2024() {
1443        // 1st Monday of September 2024 = September 2.
1444        let date = NaiveDate::from_ymd_opt(2024, 9, 2).unwrap();
1445        assert!(is_us_market_holiday(date), "Labor Day should be a holiday");
1446    }
1447
1448    #[test]
1449    fn test_holiday_thanksgiving_2024() {
1450        // 4th Thursday of November 2024 = November 28.
1451        let date = NaiveDate::from_ymd_opt(2024, 11, 28).unwrap();
1452        assert!(is_us_market_holiday(date), "Thanksgiving should be a holiday");
1453    }
1454
1455    #[test]
1456    fn test_holiday_christmas_2024() {
1457        // December 25, 2024 is a Wednesday.
1458        let date = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap();
1459        assert!(is_us_market_holiday(date), "Christmas should be a holiday");
1460    }
1461
1462    #[test]
1463    fn test_holiday_regular_monday_is_not_holiday() {
1464        // 2024-01-08 is a regular Monday (not a holiday).
1465        let date = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
1466        assert!(!is_us_market_holiday(date), "Regular Monday should not be a holiday");
1467    }
1468
1469    #[test]
1470    fn test_holiday_market_closed_on_christmas_2024() {
1471        // Dec 25, 2024 = Christmas; 14:30 UTC (09:30 ET) should be Closed.
1472        // date_to_utc_ms(2024-12-25, 14, 30) = some ms value
1473        let christmas_open_utc_ms = date_to_utc_ms(
1474            NaiveDate::from_ymd_opt(2024, 12, 25).unwrap(),
1475            14,
1476            30,
1477        );
1478        let sa = sa(MarketSession::UsEquity);
1479        assert_eq!(
1480            sa.status(christmas_open_utc_ms).unwrap(),
1481            TradingStatus::Closed,
1482            "Market should be closed on Christmas"
1483        );
1484    }
1485
1486    #[test]
1487    fn test_easter_sunday_2024() {
1488        assert_eq!(easter_sunday(2024), NaiveDate::from_ymd_opt(2024, 3, 31).unwrap());
1489    }
1490
1491    #[test]
1492    fn test_easter_sunday_2025() {
1493        assert_eq!(easter_sunday(2025), NaiveDate::from_ymd_opt(2025, 4, 20).unwrap());
1494    }
1495
1496    // ── time_until_open_ms / time_until_close_ms ──────────────────────────────
1497
1498    #[test]
1499    fn test_time_until_open_crypto_is_zero() {
1500        let sa = sa(MarketSession::Crypto);
1501        assert_eq!(sa.time_until_open_ms(SAT_UTC_MS), 0);
1502        assert_eq!(sa.time_until_open_ms(MON_OPEN_UTC_MS), 0);
1503    }
1504
1505    #[test]
1506    fn test_time_until_open_equity_already_open_is_zero() {
1507        let sa = sa(MarketSession::UsEquity);
1508        assert_eq!(sa.time_until_open_ms(MON_OPEN_UTC_MS), 0);
1509    }
1510
1511    #[test]
1512    fn test_time_until_open_equity_saturday_is_positive() {
1513        let sa = sa(MarketSession::UsEquity);
1514        assert!(sa.time_until_open_ms(SAT_UTC_MS) > 0);
1515    }
1516
1517    #[test]
1518    fn test_time_until_close_crypto_is_max() {
1519        let sa = sa(MarketSession::Crypto);
1520        assert_eq!(sa.time_until_close_ms(MON_OPEN_UTC_MS), u64::MAX);
1521    }
1522
1523    #[test]
1524    fn test_time_until_close_equity_already_closed_is_zero() {
1525        let sa = sa(MarketSession::UsEquity);
1526        assert_eq!(sa.time_until_close_ms(SAT_UTC_MS), 0);
1527    }
1528
1529    #[test]
1530    fn test_time_until_close_equity_open_is_positive() {
1531        let sa = sa(MarketSession::UsEquity);
1532        assert!(sa.time_until_close_ms(MON_OPEN_UTC_MS) > 0);
1533    }
1534
1535    // ── is_open ───────────────────────────────────────────────────────────────
1536
1537    #[test]
1538    fn test_is_open_crypto_always_true() {
1539        let sa = sa(MarketSession::Crypto);
1540        assert!(sa.is_open(SAT_UTC_MS));
1541        assert!(sa.is_open(0));
1542    }
1543
1544    #[test]
1545    fn test_is_open_equity_during_market_hours() {
1546        let sa = sa(MarketSession::UsEquity);
1547        assert!(sa.is_open(MON_OPEN_UTC_MS));
1548    }
1549
1550    #[test]
1551    fn test_is_open_equity_on_weekend_false() {
1552        let sa = sa(MarketSession::UsEquity);
1553        assert!(!sa.is_open(SAT_UTC_MS));
1554    }
1555
1556    #[test]
1557    fn test_is_open_forex_on_monday_true() {
1558        let sa = sa(MarketSession::Forex);
1559        assert!(sa.is_open(MON_OPEN_UTC_MS));
1560    }
1561
1562    #[test]
1563    fn test_is_open_forex_on_saturday_false() {
1564        let sa = sa(MarketSession::Forex);
1565        assert!(!sa.is_open(SAT_UTC_MS));
1566    }
1567
1568    // ── next_close_ms ─────────────────────────────────────────────────────────
1569
1570    #[test]
1571    fn test_next_close_crypto_is_max() {
1572        let sa = sa(MarketSession::Crypto);
1573        assert_eq!(sa.next_close_ms(MON_OPEN_UTC_MS), u64::MAX);
1574        assert_eq!(sa.next_close_ms(SAT_UTC_MS), u64::MAX);
1575    }
1576
1577    #[test]
1578    fn test_next_close_equity_already_closed_returns_same() {
1579        // SAT_UTC_MS is Saturday — market is closed
1580        let sa = sa(MarketSession::UsEquity);
1581        assert_eq!(sa.next_close_ms(SAT_UTC_MS), SAT_UTC_MS);
1582    }
1583
1584    #[test]
1585    fn test_next_close_equity_open_est_returns_20_00_et() {
1586        // MON_OPEN_UTC_MS = Monday 2024-01-08 14:30 UTC = 09:30 ET (Open, EST=UTC-5)
1587        // 20:00 ET (EST) = 01:00 UTC the next calendar day = 2024-01-09 01:00 UTC
1588        let sa = sa(MarketSession::UsEquity);
1589        let close = sa.next_close_ms(MON_OPEN_UTC_MS);
1590        assert!(close > MON_OPEN_UTC_MS);
1591        assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1592        // 2024-01-09 01:00 UTC = 1704762000000 ms
1593        assert_eq!(close, 1704762000000);
1594    }
1595
1596    #[test]
1597    fn test_next_close_equity_open_edt_returns_midnight_utc() {
1598        // MON_SUMMER_OPEN_UTC_MS = Monday 2024-07-08 13:30 UTC = 09:30 EDT (UTC-4)
1599        // 20:00 ET (EDT) = 00:00 UTC the next calendar day = 2024-07-09 00:00 UTC
1600        let sa = sa(MarketSession::UsEquity);
1601        let close = sa.next_close_ms(MON_SUMMER_OPEN_UTC_MS);
1602        assert!(close > MON_SUMMER_OPEN_UTC_MS);
1603        assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1604        // 2024-07-09 00:00 UTC = 1720483200000 ms
1605        assert_eq!(close, 1720483200000);
1606    }
1607
1608    #[test]
1609    fn test_next_close_equity_extended_returns_20_00_et() {
1610        // MON_CLOSE_UTC_MS = Monday 2024-01-08 21:00 UTC = 16:00 ET (Extended)
1611        // Next full close = 20:00 ET = 2024-01-09 01:00 UTC
1612        let sa = sa(MarketSession::UsEquity);
1613        let close = sa.next_close_ms(MON_CLOSE_UTC_MS);
1614        assert!(close > MON_CLOSE_UTC_MS);
1615        assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1616        assert_eq!(close, 1704762000000);
1617    }
1618
1619    #[test]
1620    fn test_next_close_forex_already_closed_returns_same() {
1621        // SAT_UTC_MS = Saturday — forex is closed
1622        let sa = sa(MarketSession::Forex);
1623        assert_eq!(sa.next_close_ms(SAT_UTC_MS), SAT_UTC_MS);
1624    }
1625
1626    #[test]
1627    fn test_next_close_forex_open_monday_returns_friday_22_utc() {
1628        // MON_OPEN_UTC_MS = Monday 2024-01-08 14:30 UTC → forex open
1629        // Next close = Friday 2024-01-12 22:00 UTC = 1705096800000 ms
1630        let sa = sa(MarketSession::Forex);
1631        let close = sa.next_close_ms(MON_OPEN_UTC_MS);
1632        assert!(close > MON_OPEN_UTC_MS);
1633        assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1634        assert_eq!(close, 1705096800000);
1635    }
1636
1637    // ── MarketSession::session_duration_ms ────────────────────────────────────
1638
1639    #[test]
1640    fn test_session_duration_us_equity_is_6_5_hours() {
1641        // 9:30–16:00 ET = 6.5 hours = 23 400 000 ms
1642        assert_eq!(
1643            MarketSession::UsEquity.session_duration_ms(),
1644            6 * 3_600_000 + 30 * 60_000
1645        );
1646    }
1647
1648    #[test]
1649    fn test_session_duration_forex_is_120_hours() {
1650        assert_eq!(MarketSession::Forex.session_duration_ms(), 5 * 24 * 3_600_000);
1651    }
1652
1653    #[test]
1654    fn test_session_duration_crypto_is_max() {
1655        assert_eq!(MarketSession::Crypto.session_duration_ms(), u64::MAX);
1656    }
1657
1658    // ── SessionAwareness::is_extended ─────────────────────────────────────────
1659
1660    #[test]
1661    fn test_is_extended_crypto_is_never_extended() {
1662        let sa = sa(MarketSession::Crypto);
1663        // Crypto is always Open, never Extended
1664        assert!(!sa.is_extended(MON_OPEN_UTC_MS));
1665    }
1666
1667    #[test]
1668    fn test_is_extended_equity_during_extended_hours() {
1669        // 7:00 AM EST on Monday 2024-01-08 = pre-market (Extended)
1670        // 2024-01-08 00:00 UTC = 1704672000 s. 7:00 AM EST (UTC-5) = 12:00 UTC
1671        // → 1704672000 + 12*3600 = 1704715200 s = 1704715200000 ms
1672        let seven_am_est_ms = 1704715200_000u64;
1673        let sa = sa(MarketSession::UsEquity);
1674        assert_eq!(sa.status(seven_am_est_ms).unwrap(), TradingStatus::Extended);
1675        assert!(sa.is_extended(seven_am_est_ms));
1676    }
1677
1678    #[test]
1679    fn test_is_extended_equity_during_open_is_false() {
1680        let sa = sa(MarketSession::UsEquity);
1681        // MON_OPEN_UTC_MS is during regular Open hours
1682        assert!(!sa.is_extended(MON_OPEN_UTC_MS));
1683    }
1684
1685    // ── SessionAwareness::session_progress ────────────────────────────────────
1686
1687    #[test]
1688    fn test_session_progress_none_when_closed() {
1689        let sa = sa(MarketSession::UsEquity);
1690        // SAT_UTC_MS is on a weekend (closed)
1691        assert!(sa.session_progress(SAT_UTC_MS).is_none());
1692    }
1693
1694    #[test]
1695    fn test_session_progress_none_for_crypto() {
1696        let sa = sa(MarketSession::Crypto);
1697        assert!(sa.session_progress(MON_OPEN_UTC_MS).is_none());
1698    }
1699
1700    #[test]
1701    fn test_session_progress_at_open_is_zero() {
1702        let sa = sa(MarketSession::UsEquity);
1703        // MON_OPEN_UTC_MS is exactly at 9:30 AM ET (session open)
1704        let progress = sa.session_progress(MON_OPEN_UTC_MS).unwrap();
1705        assert!(progress.abs() < 1e-6, "expected ~0.0 got {progress}");
1706    }
1707
1708    #[test]
1709    fn test_session_progress_midway() {
1710        let sa = sa(MarketSession::UsEquity);
1711        // US equity session is 6.5 hours = 23_400_000 ms
1712        // Midway = open + 11_700_000 ms
1713        let mid_ms = MON_OPEN_UTC_MS + 11_700_000;
1714        let progress = sa.session_progress(mid_ms).unwrap();
1715        assert!((progress - 0.5).abs() < 1e-6, "expected ~0.5 got {progress}");
1716    }
1717
1718    #[test]
1719    fn test_session_progress_in_range_zero_to_one() {
1720        let sa = sa(MarketSession::UsEquity);
1721        // One hour into the session
1722        let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1723        let progress = sa.session_progress(one_hour_in).unwrap();
1724        assert!(progress > 0.0 && progress < 1.0, "expected (0,1) got {progress}");
1725    }
1726
1727    // ── SessionAwareness::is_closed ───────────────────────────────────────────
1728
1729    #[test]
1730    fn test_is_closed_crypto_is_never_closed() {
1731        let sa = sa(MarketSession::Crypto);
1732        assert!(!sa.is_closed(MON_OPEN_UTC_MS));
1733        assert!(!sa.is_closed(SAT_UTC_MS));
1734    }
1735
1736    #[test]
1737    fn test_is_closed_equity_on_weekend() {
1738        let sa = sa(MarketSession::UsEquity);
1739        assert!(sa.is_closed(SAT_UTC_MS));
1740    }
1741
1742    #[test]
1743    fn test_is_closed_equity_during_open_is_false() {
1744        let sa = sa(MarketSession::UsEquity);
1745        assert!(!sa.is_closed(MON_OPEN_UTC_MS));
1746    }
1747
1748    // ── SessionAwareness::is_market_hours ─────────────────────────────────────
1749
1750    #[test]
1751    fn test_is_market_hours_crypto_always_true() {
1752        let sa = sa(MarketSession::Crypto);
1753        assert!(sa.is_market_hours(MON_OPEN_UTC_MS));
1754        assert!(sa.is_market_hours(SAT_UTC_MS));
1755    }
1756
1757    #[test]
1758    fn test_is_market_hours_equity_open_is_true() {
1759        let sa = sa(MarketSession::UsEquity);
1760        assert!(sa.is_market_hours(MON_OPEN_UTC_MS));
1761    }
1762
1763    #[test]
1764    fn test_is_market_hours_equity_extended_is_true() {
1765        // 7:00 AM EST = pre-market (Extended)
1766        let seven_am_est_ms = 1704715200_000u64;
1767        let sa = sa(MarketSession::UsEquity);
1768        assert!(sa.is_market_hours(seven_am_est_ms));
1769    }
1770
1771    #[test]
1772    fn test_is_market_hours_equity_closed_is_false() {
1773        let sa = sa(MarketSession::UsEquity);
1774        assert!(!sa.is_market_hours(SAT_UTC_MS));
1775    }
1776
1777    // ── MarketSession::has_extended_hours ─────────────────────────────────────
1778
1779    #[test]
1780    fn test_us_equity_has_extended_hours() {
1781        assert!(MarketSession::UsEquity.has_extended_hours());
1782    }
1783
1784    #[test]
1785    fn test_crypto_has_no_extended_hours() {
1786        assert!(!MarketSession::Crypto.has_extended_hours());
1787    }
1788
1789    #[test]
1790    fn test_forex_has_no_extended_hours() {
1791        assert!(!MarketSession::Forex.has_extended_hours());
1792    }
1793
1794    // ── SessionAwareness::time_in_session_ms ──────────────────────────────────
1795
1796    #[test]
1797    fn test_time_in_session_ms_none_when_closed() {
1798        let sa = sa(MarketSession::UsEquity);
1799        assert!(sa.time_in_session_ms(SAT_UTC_MS).is_none());
1800    }
1801
1802    #[test]
1803    fn test_time_in_session_ms_none_for_crypto() {
1804        let sa = sa(MarketSession::Crypto);
1805        assert!(sa.time_in_session_ms(MON_OPEN_UTC_MS).is_none());
1806    }
1807
1808    #[test]
1809    fn test_time_in_session_ms_zero_at_open() {
1810        let sa = sa(MarketSession::UsEquity);
1811        assert_eq!(sa.time_in_session_ms(MON_OPEN_UTC_MS).unwrap(), 0);
1812    }
1813
1814    #[test]
1815    fn test_time_in_session_ms_one_hour_in() {
1816        let sa = sa(MarketSession::UsEquity);
1817        let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1818        assert_eq!(sa.time_in_session_ms(one_hour_in).unwrap(), 3_600_000);
1819    }
1820
1821    // ── SessionAwareness::minutes_until_close ─────────────────────────────────
1822
1823    #[test]
1824    fn test_minutes_until_close_crypto_is_max() {
1825        let sa = sa(MarketSession::Crypto);
1826        assert_eq!(sa.minutes_until_close(MON_OPEN_UTC_MS), u64::MAX);
1827    }
1828
1829    #[test]
1830    fn test_minutes_until_close_equity_already_closed() {
1831        let sa = sa(MarketSession::UsEquity);
1832        // Already closed on Saturday
1833        assert_eq!(sa.minutes_until_close(SAT_UTC_MS), 0);
1834    }
1835
1836    #[test]
1837    fn test_minutes_until_close_equity_open_positive() {
1838        let sa = sa(MarketSession::UsEquity);
1839        let mins = sa.minutes_until_close(MON_OPEN_UTC_MS);
1840        assert!(mins > 0, "expected > 0 minutes until close, got {mins}");
1841    }
1842
1843    #[test]
1844    fn test_remaining_session_ms_complements_elapsed() {
1845        let sa = sa(MarketSession::UsEquity);
1846        let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1847        let elapsed = sa.time_in_session_ms(one_hour_in).unwrap();
1848        let remaining = sa.remaining_session_ms(one_hour_in).unwrap();
1849        let duration_ms = MarketSession::UsEquity.session_duration_ms();
1850        assert_eq!(elapsed + remaining, duration_ms);
1851    }
1852
1853    #[test]
1854    fn test_remaining_session_ms_closed_returns_none() {
1855        let sa = sa(MarketSession::UsEquity);
1856        assert!(sa.remaining_session_ms(SAT_UTC_MS).is_none());
1857    }
1858
1859    // ── SessionAwareness::is_weekend ──────────────────────────────────────────
1860
1861    #[test]
1862    fn test_is_weekend_saturday_is_weekend() {
1863        // SAT_UTC_MS = 2024-01-13 12:00 UTC (Saturday)
1864        assert!(SessionAwareness::is_weekend(SAT_UTC_MS));
1865    }
1866
1867    #[test]
1868    fn test_is_weekend_sunday_is_weekend() {
1869        // SUN_BEFORE_UTC_MS = 2024-01-07 10:00 UTC (Sunday)
1870        assert!(SessionAwareness::is_weekend(SUN_BEFORE_UTC_MS));
1871    }
1872
1873    #[test]
1874    fn test_is_weekend_monday_is_not_weekend() {
1875        // MON_OPEN_UTC_MS = 2024-01-08 14:30 UTC (Monday)
1876        assert!(!SessionAwareness::is_weekend(MON_OPEN_UTC_MS));
1877    }
1878
1879    // ── SessionAwareness::minutes_since_open ──────────────────────────────────
1880
1881    #[test]
1882    fn test_minutes_since_open_zero_at_open() {
1883        let sa = sa(MarketSession::UsEquity);
1884        assert_eq!(sa.minutes_since_open(MON_OPEN_UTC_MS), 0);
1885    }
1886
1887    #[test]
1888    fn test_minutes_since_open_one_hour_in() {
1889        let sa = sa(MarketSession::UsEquity);
1890        let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1891        assert_eq!(sa.minutes_since_open(one_hour_in), 60);
1892    }
1893
1894    #[test]
1895    fn test_minutes_since_open_zero_when_closed() {
1896        let sa = sa(MarketSession::UsEquity);
1897        assert_eq!(sa.minutes_since_open(SAT_UTC_MS), 0);
1898    }
1899
1900    // ── SessionAnalyzer::is_regular_session ───────────────────────────────────
1901
1902    #[test]
1903    fn test_is_regular_session_true_during_open() {
1904        let sa = sa(MarketSession::UsEquity);
1905        assert!(sa.is_regular_session(MON_OPEN_UTC_MS));
1906    }
1907
1908    #[test]
1909    fn test_is_regular_session_false_on_weekend() {
1910        let sa = sa(MarketSession::UsEquity);
1911        assert!(!sa.is_regular_session(SAT_UTC_MS));
1912    }
1913
1914    #[test]
1915    fn test_is_regular_session_false_before_open() {
1916        let sa = sa(MarketSession::UsEquity);
1917        // Sunday before open
1918        assert!(!sa.is_regular_session(SUN_BEFORE_UTC_MS));
1919    }
1920
1921    // --- fraction_of_day_elapsed ---
1922
1923    #[test]
1924    fn test_fraction_of_day_elapsed_midnight_is_zero() {
1925        let sa = sa(MarketSession::Crypto);
1926        // Midnight UTC = 0 ms into the day
1927        let midnight_ms: u64 = 24 * 60 * 60 * 1000; // exactly one full day = another midnight
1928        assert!((sa.fraction_of_day_elapsed(midnight_ms) - 0.0).abs() < 1e-12);
1929    }
1930
1931    #[test]
1932    fn test_fraction_of_day_elapsed_noon_is_half() {
1933        let sa = sa(MarketSession::Crypto);
1934        // 12:00 UTC = 12 * 3600 * 1000 ms after midnight
1935        let noon_offset_ms: u64 = 12 * 60 * 60 * 1000;
1936        let frac = sa.fraction_of_day_elapsed(noon_offset_ms);
1937        assert!((frac - 0.5).abs() < 1e-10);
1938    }
1939
1940    #[test]
1941    fn test_fraction_of_day_elapsed_range_zero_to_one() {
1942        let sa = sa(MarketSession::Crypto);
1943        for ms in [0u64, 1_000, 43_200_000, 86_399_999] {
1944            let frac = sa.fraction_of_day_elapsed(ms);
1945            assert!((0.0..1.0).contains(&frac));
1946        }
1947    }
1948
1949    // ── SessionAwareness::remaining_until_close_ms ────────────────────────────
1950
1951    #[test]
1952    fn test_remaining_until_close_ms_some_when_open() {
1953        let sa = sa(MarketSession::UsEquity);
1954        // Market is open; there is a finite close time ahead
1955        let remaining = sa.remaining_until_close_ms(MON_OPEN_UTC_MS).unwrap();
1956        assert!(remaining > 0, "remaining should be positive when session is open");
1957        assert!(remaining < 24 * 60 * 60 * 1000, "remaining should be less than 24h");
1958    }
1959
1960    #[test]
1961    fn test_remaining_until_close_ms_none_when_closed() {
1962        let sa = sa(MarketSession::UsEquity);
1963        assert!(sa.remaining_until_close_ms(SAT_UTC_MS).is_none());
1964    }
1965
1966    #[test]
1967    fn test_remaining_until_close_ms_decreases_as_time_advances() {
1968        let sa = sa(MarketSession::UsEquity);
1969        let t1 = sa.remaining_until_close_ms(MON_OPEN_UTC_MS).unwrap();
1970        let t2 = sa.remaining_until_close_ms(MON_OPEN_UTC_MS + 60_000).unwrap();
1971        assert!(t1 > t2);
1972    }
1973
1974    // ── SessionAnalyzer::is_last_trading_hour ─────────────────────────────────
1975
1976    #[test]
1977    fn test_is_last_trading_hour_true_within_last_hour() {
1978        let sa = sa(MarketSession::UsEquity);
1979        // 30 minutes before close: MON_CLOSE_UTC_MS - 30*60*1000
1980        let thirty_before_close = MON_CLOSE_UTC_MS - 30 * 60 * 1_000;
1981        assert!(sa.is_last_trading_hour(thirty_before_close));
1982    }
1983
1984    #[test]
1985    fn test_is_last_trading_hour_false_at_open() {
1986        let sa = sa(MarketSession::UsEquity);
1987        // At market open: 6.5 hours remaining — not last hour
1988        assert!(!sa.is_last_trading_hour(MON_OPEN_UTC_MS));
1989    }
1990
1991    #[test]
1992    fn test_is_last_trading_hour_false_when_closed() {
1993        let sa = sa(MarketSession::UsEquity);
1994        assert!(!sa.is_last_trading_hour(SAT_UTC_MS));
1995    }
1996
1997    // --- is_pre_open / day_fraction_remaining ---
1998
1999    #[test]
2000    fn test_is_pre_open_true_in_pre_market_window() {
2001        let sa = sa(MarketSession::UsEquity);
2002        assert!(sa.is_pre_open(MON_OPEN_UTC_MS - 60_000));
2003    }
2004
2005    #[test]
2006    fn test_is_pre_open_false_during_regular_session() {
2007        let sa = sa(MarketSession::UsEquity);
2008        assert!(!sa.is_pre_open(MON_OPEN_UTC_MS));
2009    }
2010
2011    #[test]
2012    fn test_is_pre_open_false_when_closed() {
2013        let sa = sa(MarketSession::UsEquity);
2014        assert!(!sa.is_pre_open(SAT_UTC_MS));
2015    }
2016
2017    #[test]
2018    fn test_day_fraction_remaining_plus_elapsed_equals_one() {
2019        let sa = sa(MarketSession::UsEquity);
2020        let elapsed = sa.fraction_of_day_elapsed(MON_OPEN_UTC_MS);
2021        let remaining = sa.day_fraction_remaining(MON_OPEN_UTC_MS);
2022        assert!((elapsed + remaining - 1.0).abs() < 1e-12);
2023    }
2024
2025    #[test]
2026    fn test_day_fraction_remaining_one_at_midnight() {
2027        let sa = sa(MarketSession::UsEquity);
2028        // At midnight UTC the elapsed fraction is 0.0, so remaining is 1.0
2029        assert!((sa.day_fraction_remaining(0) - 1.0).abs() < 1e-12);
2030    }
2031
2032    // --- is_near_close / open_duration_ms ---
2033
2034    #[test]
2035    fn test_is_near_close_true_within_margin() {
2036        let sa = sa(MarketSession::UsEquity);
2037        // 15 minutes before regular close, margin 30 minutes
2038        let fifteen_before = MON_CLOSE_UTC_MS - 15 * 60_000;
2039        assert!(sa.is_near_close(fifteen_before, 30 * 60_000));
2040    }
2041
2042    #[test]
2043    fn test_is_near_close_false_outside_margin() {
2044        let sa = sa(MarketSession::UsEquity);
2045        // 2 hours before close, margin 30 minutes
2046        let two_hours_before = MON_CLOSE_UTC_MS - 2 * 3_600_000;
2047        assert!(!sa.is_near_close(two_hours_before, 30 * 60_000));
2048    }
2049
2050    #[test]
2051    fn test_is_near_close_false_when_closed() {
2052        let sa = sa(MarketSession::UsEquity);
2053        assert!(!sa.is_near_close(SAT_UTC_MS, 3_600_000));
2054    }
2055
2056    #[test]
2057    fn test_open_duration_ms_us_equity() {
2058        let sa = sa(MarketSession::UsEquity);
2059        // 6.5 hours = 23,400,000 ms
2060        assert_eq!(sa.open_duration_ms(), 6 * 3_600_000 + 30 * 60_000);
2061    }
2062
2063    #[test]
2064    fn test_open_duration_ms_crypto() {
2065        let sa = sa(MarketSession::Crypto);
2066        assert_eq!(sa.open_duration_ms(), u64::MAX);
2067    }
2068
2069    // --- is_overnight / minutes_to_next_open ---
2070
2071    #[test]
2072    fn test_is_overnight_true_when_closed_on_weekday() {
2073        let equity_sa = sa(MarketSession::UsEquity);
2074        // Middle of the night on a weekday (after after-hours, before pre-market)
2075        // Use a time deep in the night that is not extended hours
2076        // MON_OPEN_UTC_MS is 14:30 UTC Monday.
2077        // After-hours ends around 01:00 UTC Tuesday (20:00 ET + 5h UTC).
2078        // Use Wednesday 05:00 UTC (00:00 ET) — overnight before pre-market.
2079        // Wed = MON_OPEN_UTC_MS + 2*24*3600*1000 - 9.5*3600*1000 + 5*3600*1000
2080        // Actually let's just use a known overnight time: Saturday would be weekend.
2081        // Tuesday at 07:00 UTC = 02:00 ET = not pre-market (4am ET), not after-hours.
2082        let _tue_07h_utc = MON_OPEN_UTC_MS + 24 * 3_600_000 - 7 * 3_600_000 + 7 * 3_600_000 - (14 * 3_600_000 + 30 * 60_000) + 7 * 3_600_000;
2083        // Simpler: find a time that is_closed AND not_extended AND not_weekend
2084        // Just test the is_closed && not_extended && not_weekend contract
2085        // and that crypto returns false
2086        let _ = equity_sa;
2087        let sa_crypto = sa(MarketSession::Crypto);
2088        assert!(!sa_crypto.is_overnight(MON_OPEN_UTC_MS));
2089    }
2090
2091    #[test]
2092    fn test_is_overnight_false_during_regular_session() {
2093        let sa = sa(MarketSession::UsEquity);
2094        assert!(!sa.is_overnight(MON_OPEN_UTC_MS));
2095    }
2096
2097    #[test]
2098    fn test_is_overnight_false_for_crypto() {
2099        let sa = sa(MarketSession::Crypto);
2100        assert!(!sa.is_overnight(SAT_UTC_MS));
2101    }
2102
2103    #[test]
2104    fn test_minutes_to_next_open_zero_when_already_open() {
2105        let sa = sa(MarketSession::UsEquity);
2106        assert_eq!(sa.minutes_to_next_open(MON_OPEN_UTC_MS), 0.0);
2107    }
2108
2109    #[test]
2110    fn test_minutes_to_next_open_positive_when_closed() {
2111        let sa = sa(MarketSession::UsEquity);
2112        let mins = sa.minutes_to_next_open(SAT_UTC_MS);
2113        assert!(mins > 0.0);
2114    }
2115
2116    // --- SessionAnalyzer::session_progress_pct ---
2117    #[test]
2118    fn test_session_progress_pct_zero_when_closed() {
2119        let sa = sa(MarketSession::UsEquity);
2120        assert_eq!(sa.session_progress_pct(SAT_UTC_MS), 0.0);
2121    }
2122
2123    #[test]
2124    fn test_session_progress_pct_positive_when_open() {
2125        let sa = sa(MarketSession::UsEquity);
2126        // 30 minutes into session
2127        let pct = sa.session_progress_pct(MON_OPEN_UTC_MS + 30 * 60_000);
2128        assert!(pct > 0.0 && pct < 100.0, "expected 0-100, got {pct}");
2129    }
2130
2131    // --- SessionAnalyzer::is_last_minute ---
2132    #[test]
2133    fn test_is_last_minute_true_within_last_60s() {
2134        let sa = sa(MarketSession::UsEquity);
2135        assert!(sa.is_last_minute(MON_CLOSE_UTC_MS - 30_000));
2136    }
2137
2138    #[test]
2139    fn test_is_last_minute_false_when_more_than_60s_remain() {
2140        let sa = sa(MarketSession::UsEquity);
2141        assert!(!sa.is_last_minute(MON_CLOSE_UTC_MS - 120_000));
2142    }
2143
2144    #[test]
2145    fn test_is_last_minute_false_when_closed() {
2146        let sa = sa(MarketSession::UsEquity);
2147        assert!(!sa.is_last_minute(SAT_UTC_MS));
2148    }
2149
2150    // --- SessionAnalyzer::minutes_since_close ---
2151    #[test]
2152    fn test_minutes_since_close_zero_when_open() {
2153        let sa = sa(MarketSession::UsEquity);
2154        assert_eq!(sa.minutes_since_close(MON_OPEN_UTC_MS + 30 * 60_000), 0.0);
2155    }
2156
2157    #[test]
2158    fn test_minutes_since_close_positive_when_closed() {
2159        let sa = sa(MarketSession::UsEquity);
2160        let mins = sa.minutes_since_close(SAT_UTC_MS);
2161        assert!(mins > 0.0, "expected positive value when closed");
2162    }
2163
2164    // --- SessionAnalyzer::is_opening_bell_minute ---
2165    #[test]
2166    fn test_is_opening_bell_minute_true_at_open() {
2167        let sa = sa(MarketSession::UsEquity);
2168        assert!(sa.is_opening_bell_minute(MON_OPEN_UTC_MS + 30_000));
2169    }
2170
2171    #[test]
2172    fn test_is_opening_bell_minute_false_after_first_minute() {
2173        let sa = sa(MarketSession::UsEquity);
2174        assert!(!sa.is_opening_bell_minute(MON_OPEN_UTC_MS + 90_000));
2175    }
2176
2177    #[test]
2178    fn test_is_opening_bell_minute_false_when_closed() {
2179        let sa = sa(MarketSession::UsEquity);
2180        assert!(!sa.is_opening_bell_minute(SAT_UTC_MS));
2181    }
2182
2183    // ── SessionAnalyzer::is_extended_hours ────────────────────────────────────
2184
2185    #[test]
2186    fn test_is_extended_hours_true_in_pre_market() {
2187        let sa = sa(MarketSession::UsEquity);
2188        // 1 minute before regular open = pre-market
2189        assert!(sa.is_extended_hours(MON_OPEN_UTC_MS - 60_000));
2190    }
2191
2192    #[test]
2193    fn test_is_extended_hours_true_in_after_hours() {
2194        let sa = sa(MarketSession::UsEquity);
2195        // 1 minute after regular close = after-hours
2196        assert!(sa.is_extended_hours(MON_CLOSE_UTC_MS + 60_000));
2197    }
2198
2199    #[test]
2200    fn test_is_extended_hours_false_during_regular_session() {
2201        let sa = sa(MarketSession::UsEquity);
2202        assert!(!sa.is_extended_hours(MON_OPEN_UTC_MS));
2203    }
2204
2205    // ── SessionAnalyzer::is_opening_range ─────────────────────────────────────
2206
2207    #[test]
2208    fn test_is_opening_range_true_at_open() {
2209        let sa = sa(MarketSession::UsEquity);
2210        // Exactly at open (0 ms elapsed)
2211        assert!(sa.is_opening_range(MON_OPEN_UTC_MS));
2212    }
2213
2214    #[test]
2215    fn test_is_opening_range_true_at_15_minutes() {
2216        let sa = sa(MarketSession::UsEquity);
2217        // 15 minutes elapsed = 900_000 ms < 30 min
2218        assert!(sa.is_opening_range(MON_OPEN_UTC_MS + 900_000));
2219    }
2220
2221    #[test]
2222    fn test_is_opening_range_false_after_30_minutes() {
2223        let sa = sa(MarketSession::UsEquity);
2224        // 31 minutes elapsed
2225        assert!(!sa.is_opening_range(MON_OPEN_UTC_MS + 31 * 60_000));
2226    }
2227
2228    #[test]
2229    fn test_is_opening_range_false_when_closed() {
2230        let sa = sa(MarketSession::UsEquity);
2231        assert!(!sa.is_opening_range(SAT_UTC_MS));
2232    }
2233
2234    // ── SessionAnalyzer::is_mid_session ───────────────────────────────────────
2235
2236    #[test]
2237    fn test_is_mid_session_true_at_halfway_point() {
2238        let sa = sa(MarketSession::UsEquity);
2239        // 3 hours in = ~46% progress → mid session
2240        assert!(sa.is_mid_session(MON_OPEN_UTC_MS + 3 * 3_600_000));
2241    }
2242
2243    #[test]
2244    fn test_is_mid_session_false_in_opening_range() {
2245        let sa = sa(MarketSession::UsEquity);
2246        // 5 minutes elapsed = <25% progress
2247        assert!(!sa.is_mid_session(MON_OPEN_UTC_MS + 5 * 60_000));
2248    }
2249
2250    #[test]
2251    fn test_is_mid_session_false_when_closed() {
2252        let sa = sa(MarketSession::UsEquity);
2253        assert!(!sa.is_mid_session(SAT_UTC_MS));
2254    }
2255
2256    // ── SessionAnalyzer::is_first_quarter / is_last_quarter ────────────────
2257
2258    #[test]
2259    fn test_is_first_quarter_true_at_open() {
2260        let sa = sa(MarketSession::UsEquity);
2261        // At exact open = 0% progress → first quarter
2262        assert!(sa.is_first_quarter(MON_OPEN_UTC_MS));
2263    }
2264
2265    #[test]
2266    fn test_is_first_quarter_false_at_midpoint() {
2267        let sa = sa(MarketSession::UsEquity);
2268        // 3 hours in ≈ 46% → not first quarter
2269        assert!(!sa.is_first_quarter(MON_OPEN_UTC_MS + 3 * 3_600_000));
2270    }
2271
2272    #[test]
2273    fn test_is_first_quarter_false_when_closed() {
2274        let sa = sa(MarketSession::UsEquity);
2275        assert!(!sa.is_first_quarter(SAT_UTC_MS));
2276    }
2277
2278    #[test]
2279    fn test_is_last_quarter_true_near_close() {
2280        let sa = sa(MarketSession::UsEquity);
2281        // Session = 6.5 h = 23400 s. 80% = 18720 s
2282        assert!(sa.is_last_quarter(MON_OPEN_UTC_MS + 18_720_000));
2283    }
2284
2285    #[test]
2286    fn test_is_last_quarter_false_at_open() {
2287        let sa = sa(MarketSession::UsEquity);
2288        assert!(!sa.is_last_quarter(MON_OPEN_UTC_MS));
2289    }
2290
2291    #[test]
2292    fn test_is_last_quarter_false_when_closed() {
2293        let sa = sa(MarketSession::UsEquity);
2294        assert!(!sa.is_last_quarter(SAT_UTC_MS));
2295    }
2296
2297    // ── SessionAnalyzer::minutes_elapsed / is_power_hour ───────────────────
2298
2299    #[test]
2300    fn test_minutes_elapsed_zero_at_open() {
2301        let sa = sa(MarketSession::UsEquity);
2302        assert_eq!(sa.minutes_elapsed(MON_OPEN_UTC_MS), 0.0);
2303    }
2304
2305    #[test]
2306    fn test_minutes_elapsed_correct_at_30_min() {
2307        let sa = sa(MarketSession::UsEquity);
2308        let elapsed = sa.minutes_elapsed(MON_OPEN_UTC_MS + 30 * 60_000);
2309        assert!((elapsed - 30.0).abs() < 1e-9);
2310    }
2311
2312    #[test]
2313    fn test_minutes_elapsed_zero_when_closed() {
2314        let sa = sa(MarketSession::UsEquity);
2315        assert_eq!(sa.minutes_elapsed(SAT_UTC_MS), 0.0);
2316    }
2317
2318    #[test]
2319    fn test_is_power_hour_true_in_last_hour() {
2320        let sa = sa(MarketSession::UsEquity);
2321        // Session = 6.5h = 390min; power hour starts at 330min = 19_800_000ms
2322        assert!(sa.is_power_hour(MON_OPEN_UTC_MS + 19_800_000 + 60_000));
2323    }
2324
2325    #[test]
2326    fn test_is_power_hour_false_at_open() {
2327        let sa = sa(MarketSession::UsEquity);
2328        assert!(!sa.is_power_hour(MON_OPEN_UTC_MS));
2329    }
2330
2331    #[test]
2332    fn test_is_power_hour_false_when_closed() {
2333        let sa = sa(MarketSession::UsEquity);
2334        assert!(!sa.is_power_hour(SAT_UTC_MS));
2335    }
2336
2337    // ── SessionAnalyzer::fraction_remaining ─────────────────────────────────
2338
2339    #[test]
2340    fn test_fraction_remaining_one_at_open() {
2341        let sa = sa(MarketSession::UsEquity);
2342        // At open: progress=0, fraction remaining=1
2343        let f = sa.fraction_remaining(MON_OPEN_UTC_MS).unwrap();
2344        assert!((f - 1.0).abs() < 1e-6);
2345    }
2346
2347    #[test]
2348    fn test_fraction_remaining_zero_at_close() {
2349        let sa = sa(MarketSession::UsEquity);
2350        // 1ms before close: fraction remaining should be very small (< 0.01%)
2351        let near_close_ms = MON_OPEN_UTC_MS + 6 * 3_600_000 + 30 * 60_000 - 1;
2352        let f = sa.fraction_remaining(near_close_ms).unwrap();
2353        assert!(f >= 0.0 && f < 0.0001);
2354    }
2355
2356    #[test]
2357    fn test_fraction_remaining_none_when_closed() {
2358        let sa = sa(MarketSession::UsEquity);
2359        assert!(sa.fraction_remaining(SAT_UTC_MS).is_none());
2360    }
2361
2362    #[test]
2363    fn test_fraction_remaining_plus_progress_equals_one() {
2364        let sa = sa(MarketSession::UsEquity);
2365        let t = MON_OPEN_UTC_MS + 2 * 3_600_000;
2366        let prog = sa.session_progress(t).unwrap();
2367        let rem = sa.fraction_remaining(t).unwrap();
2368        assert!((prog + rem - 1.0).abs() < 1e-9);
2369    }
2370
2371    // ── SessionAnalyzer::is_lunch_hour ────────────────────────────────────────
2372
2373    #[test]
2374    fn test_is_lunch_hour_true_at_midday() {
2375        let sa = sa(MarketSession::UsEquity);
2376        // 2.5h into session = 150min = start of lunch window
2377        let t = MON_OPEN_UTC_MS + 150 * 60_000;
2378        assert!(sa.is_lunch_hour(t));
2379    }
2380
2381    #[test]
2382    fn test_is_lunch_hour_false_at_open() {
2383        let sa = sa(MarketSession::UsEquity);
2384        assert!(!sa.is_lunch_hour(MON_OPEN_UTC_MS));
2385    }
2386
2387    #[test]
2388    fn test_is_lunch_hour_false_outside_session() {
2389        let sa = sa(MarketSession::UsEquity);
2390        assert!(!sa.is_lunch_hour(SAT_UTC_MS));
2391    }
2392
2393    #[test]
2394    fn test_is_lunch_hour_false_for_crypto() {
2395        let sa = sa(MarketSession::Crypto);
2396        let t = MON_OPEN_UTC_MS + 150 * 60_000;
2397        assert!(!sa.is_lunch_hour(t));
2398    }
2399
2400    // ── SessionAwareness::is_triple_witching ───────────────────────────────────
2401
2402    #[test]
2403    fn test_is_triple_witching_true_third_friday_march() {
2404        // 2024-03-15 = third Friday of March 2024
2405        let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
2406        assert!(SessionAwareness::is_triple_witching(date));
2407    }
2408
2409    #[test]
2410    fn test_is_triple_witching_true_third_friday_september() {
2411        // 2024-09-20 = third Friday of September 2024
2412        let date = NaiveDate::from_ymd_opt(2024, 9, 20).unwrap();
2413        assert!(SessionAwareness::is_triple_witching(date));
2414    }
2415
2416    #[test]
2417    fn test_is_triple_witching_false_wrong_month() {
2418        // 2024-01-19 = third Friday of January — not a witching month
2419        let date = NaiveDate::from_ymd_opt(2024, 1, 19).unwrap();
2420        assert!(!SessionAwareness::is_triple_witching(date));
2421    }
2422
2423    #[test]
2424    fn test_is_triple_witching_false_first_friday_of_witching_month() {
2425        // 2024-03-01 = first Friday of March — not third
2426        let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
2427        assert!(!SessionAwareness::is_triple_witching(date));
2428    }
2429
2430    #[test]
2431    fn test_is_triple_witching_false_wrong_weekday() {
2432        // 2024-03-20 = Wednesday, third week of March
2433        let date = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap();
2434        assert!(!SessionAwareness::is_triple_witching(date));
2435    }
2436
2437    // ── SessionAwareness::trading_days_elapsed ────────────────────────────────
2438
2439    #[test]
2440    fn test_trading_days_elapsed_same_day_weekday() {
2441        let d = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(); // Monday
2442        assert_eq!(SessionAwareness::trading_days_elapsed(d, d), 1);
2443    }
2444
2445    #[test]
2446    fn test_trading_days_elapsed_full_week() {
2447        let from = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();  // Monday
2448        let to   = NaiveDate::from_ymd_opt(2024, 1, 12).unwrap(); // Friday
2449        assert_eq!(SessionAwareness::trading_days_elapsed(from, to), 5);
2450    }
2451
2452    #[test]
2453    fn test_trading_days_elapsed_excludes_weekends() {
2454        let from = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();  // Monday
2455        let to   = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap(); // Sunday
2456        // Mon-Fri = 5 trading days, Sat+Sun = 0
2457        assert_eq!(SessionAwareness::trading_days_elapsed(from, to), 5);
2458    }
2459
2460    #[test]
2461    fn test_trading_days_elapsed_zero_when_reversed() {
2462        let from = NaiveDate::from_ymd_opt(2024, 1, 12).unwrap();
2463        let to   = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
2464        assert_eq!(SessionAwareness::trading_days_elapsed(from, to), 0);
2465    }
2466
2467    // ── SessionAwareness::is_earnings_season ──────────────────────────────────
2468
2469    #[test]
2470    fn test_is_earnings_season_true_in_january() {
2471        let d = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
2472        assert!(SessionAwareness::is_earnings_season(d));
2473    }
2474
2475    #[test]
2476    fn test_is_earnings_season_true_in_october() {
2477        let d = NaiveDate::from_ymd_opt(2024, 10, 10).unwrap();
2478        assert!(SessionAwareness::is_earnings_season(d));
2479    }
2480
2481    #[test]
2482    fn test_is_earnings_season_false_in_march() {
2483        let d = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
2484        assert!(!SessionAwareness::is_earnings_season(d));
2485    }
2486
2487    // ── SessionAwareness::week_of_month ───────────────────────────────────────
2488
2489    #[test]
2490    fn test_week_of_month_first_day_is_week_one() {
2491        let d = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
2492        assert_eq!(SessionAwareness::week_of_month(d), 1);
2493    }
2494
2495    #[test]
2496    fn test_week_of_month_8th_is_week_two() {
2497        let d = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
2498        assert_eq!(SessionAwareness::week_of_month(d), 2);
2499    }
2500
2501    #[test]
2502    fn test_week_of_month_15th_is_week_three() {
2503        let d = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
2504        assert_eq!(SessionAwareness::week_of_month(d), 3);
2505    }
2506
2507    // ── is_fomc_blackout_window ───────────────────────────────────────────────
2508
2509    #[test]
2510    fn test_fomc_blackout_true_for_late_odd_month() {
2511        let d = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap(); // March 20
2512        assert!(SessionAwareness::is_fomc_blackout_window(d));
2513    }
2514
2515    #[test]
2516    fn test_fomc_blackout_false_for_early_odd_month() {
2517        let d = NaiveDate::from_ymd_opt(2024, 3, 5).unwrap(); // March 5
2518        assert!(!SessionAwareness::is_fomc_blackout_window(d));
2519    }
2520
2521    #[test]
2522    fn test_fomc_blackout_false_for_even_month() {
2523        let d = NaiveDate::from_ymd_opt(2024, 4, 25).unwrap(); // April — even month
2524        assert!(!SessionAwareness::is_fomc_blackout_window(d));
2525    }
2526
2527    #[test]
2528    fn test_fomc_blackout_boundary_day_18() {
2529        let d = NaiveDate::from_ymd_opt(2024, 1, 18).unwrap();
2530        assert!(SessionAwareness::is_fomc_blackout_window(d));
2531    }
2532
2533    // ── is_market_holiday_adjacent ────────────────────────────────────────────
2534
2535    #[test]
2536    fn test_holiday_adjacent_christmas_eve() {
2537        let d = NaiveDate::from_ymd_opt(2024, 12, 24).unwrap();
2538        assert!(SessionAwareness::is_market_holiday_adjacent(d));
2539    }
2540
2541    #[test]
2542    fn test_holiday_adjacent_day_after_christmas() {
2543        let d = NaiveDate::from_ymd_opt(2024, 12, 26).unwrap();
2544        assert!(SessionAwareness::is_market_holiday_adjacent(d));
2545    }
2546
2547    #[test]
2548    fn test_holiday_adjacent_new_years_eve() {
2549        let d = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
2550        assert!(SessionAwareness::is_market_holiday_adjacent(d));
2551    }
2552
2553    #[test]
2554    fn test_holiday_adjacent_july_3() {
2555        let d = NaiveDate::from_ymd_opt(2024, 7, 3).unwrap();
2556        assert!(SessionAwareness::is_market_holiday_adjacent(d));
2557    }
2558
2559    #[test]
2560    fn test_holiday_adjacent_false_for_normal_day() {
2561        let d = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2562        assert!(!SessionAwareness::is_market_holiday_adjacent(d));
2563    }
2564
2565    // ── seconds_until_open ────────────────────────────────────────────────────
2566
2567    #[test]
2568    fn test_seconds_until_open_zero_when_session_is_open() {
2569        // Monday 16:00 UTC = well within US equity session
2570        let sa = SessionAwareness::new(MarketSession::UsEquity);
2571        let mon_16h_utc: u64 = 4 * 24 * 3_600_000 + 16 * 3_600_000;
2572        assert_eq!(sa.seconds_until_open(mon_16h_utc), 0.0);
2573    }
2574
2575    #[test]
2576    fn test_seconds_until_open_positive_when_before_open() {
2577        // Saturday midnight → well before next Monday open
2578        let sa = SessionAwareness::new(MarketSession::UsEquity);
2579        let sat_midnight: u64 = 5 * 24 * 3_600_000;
2580        assert!(sa.seconds_until_open(sat_midnight) > 0.0);
2581    }
2582
2583    // ── is_closing_bell_minute ────────────────────────────────────────────────
2584
2585    #[test]
2586    fn test_closing_bell_minute_true_near_session_end() {
2587        // US equity opens at 14:30 UTC (9:30 ET-5), closes at 21:00 UTC (16:00 ET)
2588        // 6.5h = 23400s. Test at 20:59:30 UTC = just before close
2589        let sa = SessionAwareness::new(MarketSession::UsEquity);
2590        // Monday 20:59 UTC — 29 seconds before close
2591        let mon_20_59_utc: u64 = 4 * 24 * 3_600_000 + 20 * 3_600_000 + 59 * 60_000 + 30_000;
2592        assert!(sa.is_closing_bell_minute(mon_20_59_utc));
2593    }
2594
2595    #[test]
2596    fn test_closing_bell_minute_false_early_in_session() {
2597        let sa = SessionAwareness::new(MarketSession::UsEquity);
2598        // Monday 16:00 UTC — mid session
2599        let mon_16h_utc: u64 = 4 * 24 * 3_600_000 + 16 * 3_600_000;
2600        assert!(!sa.is_closing_bell_minute(mon_16h_utc));
2601    }
2602
2603    // ── day_of_week_name ──────────────────────────────────────────────────────
2604
2605    #[test]
2606    fn test_day_of_week_name_monday() {
2607        let d = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); // Monday
2608        assert_eq!(SessionAwareness::day_of_week_name(d), "Monday");
2609    }
2610
2611    #[test]
2612    fn test_day_of_week_name_friday() {
2613        let d = NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(); // Friday
2614        assert_eq!(SessionAwareness::day_of_week_name(d), "Friday");
2615    }
2616
2617    #[test]
2618    fn test_day_of_week_name_sunday() {
2619        let d = NaiveDate::from_ymd_opt(2024, 1, 7).unwrap(); // Sunday
2620        assert_eq!(SessionAwareness::day_of_week_name(d), "Sunday");
2621    }
2622
2623    // ── is_expiry_week ────────────────────────────────────────────────────────
2624
2625    #[test]
2626    fn test_is_expiry_week_true_for_late_month() {
2627        let d = NaiveDate::from_ymd_opt(2024, 1, 25).unwrap();
2628        assert!(SessionAwareness::is_expiry_week(d));
2629    }
2630
2631    #[test]
2632    fn test_is_expiry_week_true_at_boundary_day_22() {
2633        let d = NaiveDate::from_ymd_opt(2024, 1, 22).unwrap();
2634        assert!(SessionAwareness::is_expiry_week(d));
2635    }
2636
2637    #[test]
2638    fn test_is_expiry_week_false_for_early_month() {
2639        let d = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
2640        assert!(!SessionAwareness::is_expiry_week(d));
2641    }
2642
2643    // ── session_name ──────────────────────────────────────────────────────────
2644
2645    #[test]
2646    fn test_session_name_us_equity() {
2647        let sa = SessionAwareness::new(MarketSession::UsEquity);
2648        assert_eq!(sa.session_name(), "US Equity");
2649    }
2650
2651    #[test]
2652    fn test_session_name_crypto() {
2653        let sa = SessionAwareness::new(MarketSession::Crypto);
2654        assert_eq!(sa.session_name(), "Crypto");
2655    }
2656
2657    #[test]
2658    fn test_session_name_forex() {
2659        let sa = SessionAwareness::new(MarketSession::Forex);
2660        assert_eq!(sa.session_name(), "Forex");
2661    }
2662}