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