1use crate::error::StreamError;
14use chrono::{Datelike, Duration, NaiveDate, TimeZone, Timelike, Utc, Weekday};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
18pub enum MarketSession {
19 UsEquity,
21 Crypto,
23 Forex,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29pub enum TradingStatus {
30 Open,
32 Extended,
34 Closed,
36}
37
38impl MarketSession {
39 pub fn session_duration_ms(self) -> u64 {
45 match self {
46 MarketSession::UsEquity => 6 * 3_600_000 + 30 * 60_000, MarketSession::Forex => 5 * 24 * 3_600_000, MarketSession::Crypto => u64::MAX,
49 }
50 }
51
52 pub fn has_extended_hours(self) -> bool {
58 matches!(self, MarketSession::UsEquity)
59 }
60}
61
62impl std::fmt::Display for MarketSession {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 MarketSession::UsEquity => write!(f, "UsEquity"),
66 MarketSession::Crypto => write!(f, "Crypto"),
67 MarketSession::Forex => write!(f, "Forex"),
68 }
69 }
70}
71
72impl std::fmt::Display for TradingStatus {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 match self {
75 TradingStatus::Open => write!(f, "Open"),
76 TradingStatus::Extended => write!(f, "Extended"),
77 TradingStatus::Closed => write!(f, "Closed"),
78 }
79 }
80}
81
82impl std::str::FromStr for MarketSession {
83 type Err = StreamError;
84
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
89 match s.to_lowercase().as_str() {
90 "usequity" => Ok(MarketSession::UsEquity),
91 "crypto" => Ok(MarketSession::Crypto),
92 "forex" => Ok(MarketSession::Forex),
93 _ => Err(StreamError::ConfigError {
94 reason: format!("unknown market session '{s}'; expected usequity, crypto, or forex"),
95 }),
96 }
97 }
98}
99
100impl std::str::FromStr for TradingStatus {
101 type Err = StreamError;
102
103 fn from_str(s: &str) -> Result<Self, Self::Err> {
107 match s.to_lowercase().as_str() {
108 "open" => Ok(TradingStatus::Open),
109 "extended" => Ok(TradingStatus::Extended),
110 "closed" => Ok(TradingStatus::Closed),
111 _ => Err(StreamError::ConfigError {
112 reason: format!(
113 "unknown trading status '{s}'; expected open, extended, or closed"
114 ),
115 }),
116 }
117 }
118}
119
120pub struct SessionAwareness {
122 session: MarketSession,
123}
124
125impl SessionAwareness {
126 pub fn new(session: MarketSession) -> Self {
128 Self { session }
129 }
130
131 pub fn is_weekend(utc_ms: u64) -> bool {
137 let dt = Utc.timestamp_millis_opt(utc_ms as i64).unwrap();
138 let weekday = dt.weekday();
139 weekday == Weekday::Sat || weekday == Weekday::Sun
140 }
141
142 pub fn status(&self, utc_ms: u64) -> Result<TradingStatus, StreamError> {
144 match self.session {
145 MarketSession::Crypto => Ok(TradingStatus::Open),
146 MarketSession::UsEquity => Ok(self.us_equity_status(utc_ms)),
147 MarketSession::Forex => Ok(self.forex_status(utc_ms)),
148 }
149 }
150
151 pub fn session(&self) -> MarketSession {
153 self.session
154 }
155
156 pub fn next_open_ms(&self, utc_ms: u64) -> u64 {
162 match self.session {
163 MarketSession::Crypto => utc_ms,
164 MarketSession::Forex => self.next_forex_open_ms(utc_ms),
165 MarketSession::UsEquity => self.next_us_equity_open_ms(utc_ms),
166 }
167 }
168
169 pub fn is_closed(&self, utc_ms: u64) -> bool {
174 self.status(utc_ms).map_or(false, |s| s == TradingStatus::Closed)
175 }
176
177 pub fn is_extended(&self, utc_ms: u64) -> bool {
183 self.status(utc_ms).map_or(false, |s| s == TradingStatus::Extended)
184 }
185
186 pub fn is_open(&self, utc_ms: u64) -> bool {
191 self.status(utc_ms).map_or(false, |s| s == TradingStatus::Open)
192 }
193
194 pub fn is_market_hours(&self, utc_ms: u64) -> bool {
201 self.is_active(utc_ms)
202 }
203
204 pub fn minutes_until_open(&self, utc_ms: u64) -> u64 {
209 self.time_until_open_ms(utc_ms) / 60_000
210 }
211
212 pub fn time_until_open_ms(&self, utc_ms: u64) -> u64 {
217 self.next_open_ms(utc_ms).saturating_sub(utc_ms)
218 }
219
220 pub fn seconds_until_open(&self, utc_ms: u64) -> f64 {
224 self.time_until_open_ms(utc_ms) as f64 / 1_000.0
225 }
226
227 pub fn minutes_until_close(&self, utc_ms: u64) -> u64 {
233 let ms = self.time_until_close_ms(utc_ms);
234 if ms == u64::MAX {
235 return u64::MAX;
236 }
237 ms / 60_000
238 }
239
240 pub fn time_until_close_ms(&self, utc_ms: u64) -> u64 {
245 let close = self.next_close_ms(utc_ms);
246 if close == u64::MAX {
247 u64::MAX
248 } else {
249 close.saturating_sub(utc_ms)
250 }
251 }
252
253 pub fn bars_until_open(&self, utc_ms: u64, bar_duration_ms: u64) -> u64 {
258 if bar_duration_ms == 0 || self.is_open(utc_ms) {
259 return 0;
260 }
261 let ms_until = self.time_until_open_ms(utc_ms);
262 ms_until / bar_duration_ms
263 }
264
265 pub fn is_pre_market(&self, utc_ms: u64) -> bool {
270 if self.session != MarketSession::UsEquity {
271 return false;
272 }
273 let Some(t) = self.et_trading_secs_of_day(utc_ms) else { return false; };
274 let pre_open = 4 * 3600_u64;
275 let market_open = 9 * 3600 + 30 * 60_u64;
276 t >= pre_open && t < market_open
277 }
278
279 pub fn is_after_hours(&self, utc_ms: u64) -> bool {
284 if self.session != MarketSession::UsEquity {
285 return false;
286 }
287 let Some(t) = self.et_trading_secs_of_day(utc_ms) else { return false; };
288 let market_close = 16 * 3600_u64;
289 let post_close = 20 * 3600_u64;
290 t >= market_close && t < post_close
291 }
292
293 fn et_trading_secs_of_day(&self, utc_ms: u64) -> Option<u64> {
295 let secs = (utc_ms / 1000) as i64;
296 let dt = chrono::DateTime::<chrono::Utc>::from_timestamp(secs, 0)
297 .unwrap_or_else(chrono::Utc::now);
298 let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
299 let et_dt = dt + chrono::Duration::seconds(et_offset_secs);
300 let dow = et_dt.weekday();
301 if dow == chrono::Weekday::Sat || dow == chrono::Weekday::Sun {
302 return None;
303 }
304 if is_us_market_holiday(et_dt.date_naive()) {
305 return None;
306 }
307 Some(et_dt.num_seconds_from_midnight() as u64)
308 }
309
310 pub fn is_extended_hours(&self, utc_ms: u64) -> bool {
313 self.is_pre_market(utc_ms) || self.is_after_hours(utc_ms)
314 }
315
316 pub fn is_active(&self, utc_ms: u64) -> bool {
318 matches!(self.status(utc_ms), Ok(TradingStatus::Open) | Ok(TradingStatus::Extended))
319 }
320
321 pub fn next_close_ms(&self, utc_ms: u64) -> u64 {
327 match self.session {
328 MarketSession::Crypto => u64::MAX,
329 MarketSession::Forex => self.next_forex_close_ms(utc_ms),
330 MarketSession::UsEquity => self.next_us_equity_close_ms(utc_ms),
331 }
332 }
333
334 pub fn session_label(&self, utc_ms: u64) -> &'static str {
339 match self.session {
340 MarketSession::Crypto => "open",
341 MarketSession::Forex => {
342 if self.is_open(utc_ms) { "open" } else { "closed" }
343 }
344 MarketSession::UsEquity => {
345 if self.is_open(utc_ms) {
346 "open"
347 } else if self.is_pre_market(utc_ms) {
348 "pre-market"
349 } else if self.is_after_hours(utc_ms) {
350 "after-hours"
351 } else {
352 "closed"
353 }
354 }
355 }
356 }
357
358 pub fn is_liquid(&self, utc_ms: u64) -> bool {
363 self.is_open(utc_ms)
364 }
365
366 pub fn session_progress(&self, utc_ms: u64) -> Option<f64> {
375 if !self.is_open(utc_ms) {
376 return None;
377 }
378 let duration_ms = self.session.session_duration_ms();
379 if duration_ms == u64::MAX {
380 return None; }
382 let look_before = utc_ms.saturating_sub(duration_ms);
385 let open_ms = self.next_open_ms(look_before);
386 let elapsed = utc_ms.saturating_sub(open_ms);
387 Some((elapsed as f64 / duration_ms as f64).clamp(0.0, 1.0))
388 }
389
390 pub fn time_in_session_ms(&self, utc_ms: u64) -> Option<u64> {
398 if !self.is_open(utc_ms) {
399 return None;
400 }
401 let duration_ms = self.session.session_duration_ms();
402 if duration_ms == u64::MAX {
403 return None;
404 }
405 let look_before = utc_ms.saturating_sub(duration_ms);
406 let open_ms = self.next_open_ms(look_before);
407 Some(utc_ms.saturating_sub(open_ms))
408 }
409
410 pub fn remaining_session_ms(&self, utc_ms: u64) -> Option<u64> {
418 let elapsed = self.time_in_session_ms(utc_ms)?;
419 let duration_ms = self.session.session_duration_ms();
420 Some(duration_ms.saturating_sub(elapsed))
421 }
422
423 pub fn fraction_of_day_elapsed(&self, utc_ms: u64) -> f64 {
429 const MS_PER_DAY: f64 = 24.0 * 60.0 * 60.0 * 1000.0;
430 let ms_in_day = utc_ms % (24 * 60 * 60 * 1000);
431 ms_in_day as f64 / MS_PER_DAY
432 }
433
434 pub fn minutes_since_open(&self, utc_ms: u64) -> u64 {
439 self.time_in_session_ms(utc_ms)
440 .map(|ms| ms / 60_000)
441 .unwrap_or(0)
442 }
443
444 pub fn remaining_until_close_ms(&self, utc_ms: u64) -> Option<u64> {
451 if !self.is_regular_session(utc_ms) {
452 return None;
453 }
454 let close = self.next_close_ms(utc_ms);
455 if close == u64::MAX {
456 return None;
457 }
458 Some(close.saturating_sub(utc_ms))
459 }
460
461 #[deprecated(since = "2.2.0", note = "Use `is_pre_market` instead")]
466 pub fn is_pre_open(&self, utc_ms: u64) -> bool {
467 self.is_pre_market(utc_ms)
468 }
469
470 pub fn day_fraction_remaining(&self, utc_ms: u64) -> f64 {
476 1.0 - self.fraction_of_day_elapsed(utc_ms)
477 }
478
479 pub fn is_regular_session(&self, utc_ms: u64) -> bool {
482 self.is_open(utc_ms)
483 }
484
485 pub fn is_last_trading_hour(&self, utc_ms: u64) -> bool {
488 self.remaining_session_ms(utc_ms).map_or(false, |r| r <= 3_600_000)
489 }
490
491 pub fn is_near_close(&self, utc_ms: u64, margin_ms: u64) -> bool {
498 self.remaining_session_ms(utc_ms).map_or(false, |r| r <= margin_ms)
499 }
500
501 pub fn open_duration_ms(&self) -> u64 {
507 self.session.session_duration_ms()
508 }
509
510 pub fn is_opening_range(&self, utc_ms: u64) -> bool {
515 self.time_in_session_ms(utc_ms).map_or(false, |e| e < 30 * 60 * 1_000)
516 }
517
518 pub fn is_mid_session(&self, utc_ms: u64) -> bool {
524 self.session_progress(utc_ms).map_or(false, |p| p >= 0.25 && p <= 0.75)
525 }
526
527 pub fn is_first_half(&self, utc_ms: u64) -> bool {
531 self.session_progress(utc_ms).map_or(false, |p| p < 0.5)
532 }
533
534 pub fn session_half(&self, utc_ms: u64) -> u8 {
537 self.session_progress(utc_ms).map_or(0, |p| if p < 0.5 { 1 } else { 2 })
538 }
539
540 pub fn overlaps_with(&self, other: &SessionAwareness, utc_ms: u64) -> bool {
544 self.is_open(utc_ms) && other.is_open(utc_ms)
545 }
546
547 pub fn open_ms(&self, utc_ms: u64) -> u64 {
551 self.time_in_session_ms(utc_ms).unwrap_or(0)
552 }
553
554 pub fn progress_pct(&self, utc_ms: u64) -> f64 {
558 self.session_progress(utc_ms).unwrap_or(0.0) * 100.0
559 }
560
561 pub fn remaining_ms(&self, utc_ms: u64) -> u64 {
565 let elapsed = self.time_in_session_ms(utc_ms).unwrap_or(0);
566 let duration = self.session.session_duration_ms();
567 if duration == u64::MAX { return u64::MAX; }
568 duration.saturating_sub(elapsed)
569 }
570
571 pub fn is_first_quarter(&self, utc_ms: u64) -> bool {
575 self.session_progress(utc_ms).map_or(false, |p| p < 0.25)
576 }
577
578 pub fn is_last_quarter(&self, utc_ms: u64) -> bool {
582 self.session_progress(utc_ms).map_or(false, |p| p > 0.75)
583 }
584
585 pub fn minutes_elapsed(&self, utc_ms: u64) -> f64 {
589 self.time_in_session_ms(utc_ms).unwrap_or(0) as f64 / 60_000.0
590 }
591
592 pub fn is_power_hour(&self, utc_ms: u64) -> bool {
596 self.session_progress(utc_ms).map_or(false, |p| p > (5.5 / 6.5))
597 }
598
599 pub fn is_overnight(&self, utc_ms: u64) -> bool {
606 if self.session != crate::session::MarketSession::UsEquity {
607 return false;
608 }
609 !self.is_open(utc_ms) && !self.is_extended_hours(utc_ms) && !Self::is_weekend(utc_ms)
610 }
611
612 pub fn minutes_to_next_open(&self, utc_ms: u64) -> f64 {
618 self.time_until_open_ms(utc_ms) as f64 / 60_000.0
619 }
620
621 #[deprecated(since = "2.2.0", note = "Use `progress_pct` instead")]
625 pub fn session_progress_pct(&self, utc_ms: u64) -> f64 {
626 self.progress_pct(utc_ms)
627 }
628
629 pub fn is_last_minute(&self, utc_ms: u64) -> bool {
632 self.remaining_session_ms(utc_ms).map_or(false, |r| r <= 60_000)
633 }
634
635 pub fn week_of_month(date: NaiveDate) -> u32 {
639 (date.day() - 1) / 7 + 1
640 }
641
642 pub fn day_of_week_name(date: NaiveDate) -> &'static str {
647 match date.weekday() {
648 Weekday::Mon => "Monday",
649 Weekday::Tue => "Tuesday",
650 Weekday::Wed => "Wednesday",
651 Weekday::Thu => "Thursday",
652 Weekday::Fri => "Friday",
653 Weekday::Sat => "Saturday",
654 Weekday::Sun => "Sunday",
655 }
656 }
657
658 pub fn is_expiry_week(date: NaiveDate) -> bool {
664 date.day() >= 22
665 }
666
667 pub fn session_name(&self) -> &'static str {
669 match self.session {
670 MarketSession::UsEquity => "US Equity",
671 MarketSession::Crypto => "Crypto",
672 MarketSession::Forex => "Forex",
673 }
674 }
675
676 pub fn is_earnings_season(date: NaiveDate) -> bool {
681 matches!(date.month(), 1 | 4 | 7 | 10)
682 }
683
684 pub fn is_lunch_hour(&self, utc_ms: u64) -> bool {
689 if self.session != MarketSession::UsEquity {
690 return false;
691 }
692 self.time_in_session_ms(utc_ms).map_or(false, |e| e >= 150 * 60 * 1_000 && e < 210 * 60 * 1_000)
693 }
694
695 pub fn is_triple_witching(date: NaiveDate) -> bool {
699 let month = date.month();
700 if !matches!(month, 3 | 6 | 9 | 12) {
701 return false;
702 }
703 if date.weekday() != Weekday::Fri {
704 return false;
705 }
706 let day = date.day();
708 day >= 15 && day <= 21
709 }
710
711 pub fn trading_days_elapsed(from: NaiveDate, to: NaiveDate) -> u32 {
715 if from > to {
716 return 0;
717 }
718 let total_days = (to - from).num_days() + 1;
719 let mut weekdays = 0u32;
720 let mut d = from;
721 for _ in 0..total_days {
722 if !matches!(d.weekday(), Weekday::Sat | Weekday::Sun) {
723 weekdays += 1;
724 }
725 if let Some(next) = d.succ_opt() {
726 d = next;
727 }
728 }
729 weekdays
730 }
731
732 pub fn fraction_remaining(&self, utc_ms: u64) -> Option<f64> {
736 self.session_progress(utc_ms).map(|p| 1.0 - p)
737 }
738
739 pub fn minutes_since_close(&self, utc_ms: u64) -> f64 {
744 if self.is_open(utc_ms) {
745 return 0.0;
746 }
747 (self.time_until_open_ms(utc_ms) as f64) / 60_000.0
748 }
749
750 pub fn is_opening_bell_minute(&self, utc_ms: u64) -> bool {
753 self.time_in_session_ms(utc_ms).map_or(false, |e| e <= 60_000)
754 }
755
756 pub fn is_closing_bell_minute(&self, utc_ms: u64) -> bool {
760 if self.session != MarketSession::UsEquity {
761 return false;
762 }
763 const SESSION_LENGTH_MS: u64 = 6 * 3_600_000 + 30 * 60_000; self.time_in_session_ms(utc_ms).map_or(false, |e| e + 60_000 >= SESSION_LENGTH_MS)
765 }
766
767 pub fn is_market_holiday_adjacent(date: NaiveDate) -> bool {
774 let month = date.month();
775 let day = date.day();
776 if month == 12 && (day == 24 || day == 26) {
778 return true;
779 }
780 if (month == 12 && day == 31) || (month == 1 && day == 2) {
782 return true;
783 }
784 if month == 7 && (day == 3 || day == 5) {
786 return true;
787 }
788 if month == 11 && date.weekday() == Weekday::Fri {
790 let d = day;
791 if d >= 23 && d <= 29 {
792 return true;
793 }
794 }
795 false
796 }
797
798 pub fn is_fomc_blackout_window(date: NaiveDate) -> bool {
805 let day = date.day();
806 matches!(date.month(), 1 | 3 | 5 | 7 | 9 | 11) && day >= 18
809 }
810
811 fn next_forex_close_ms(&self, utc_ms: u64) -> u64 {
812 if self.forex_status(utc_ms) == TradingStatus::Closed {
813 return utc_ms;
814 }
815 let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; let day_ms = utc_ms % (24 * 3600 * 1000);
818 let start_of_day = utc_ms - day_ms;
819 let hour_22_ms: u64 = 22 * 3600 * 1000;
820
821 let days_to_friday: u64 = match day_of_week {
823 0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1, 5 => 0, _ => 6, };
831 start_of_day + days_to_friday * 24 * 3600 * 1000 + hour_22_ms
832 }
833
834 fn next_us_equity_close_ms(&self, utc_ms: u64) -> u64 {
835 if self.us_equity_status(utc_ms) == TradingStatus::Closed {
836 return utc_ms;
837 }
838 let secs = (utc_ms / 1000) as i64;
841 let dt = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
842 let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
843 let et_dt = dt + Duration::seconds(et_offset_secs);
844 let et_date = et_dt.date_naive();
845
846 let utc_date = et_date + Duration::days(1);
848 let close_hour_utc: u32 = if is_us_dst(utc_ms) { 0 } else { 1 };
849 let approx_ms = date_to_utc_ms(utc_date, close_hour_utc, 0);
850 let corrected_hour: u32 = if is_us_dst(approx_ms) { 0 } else { 1 };
851 date_to_utc_ms(utc_date, corrected_hour, 0)
852 }
853
854 fn next_forex_open_ms(&self, utc_ms: u64) -> u64 {
855 if self.forex_status(utc_ms) == TradingStatus::Open {
856 return utc_ms;
857 }
858 let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; let day_ms = utc_ms % (24 * 3600 * 1000);
862 let start_of_day = utc_ms - day_ms;
863 let hour_22_ms: u64 = 22 * 3600 * 1000;
864
865 let days_to_sunday: u64 = match day_of_week {
866 0 => 0, 5 => 2, 6 => 1, _ => 0, };
871 start_of_day + days_to_sunday * 24 * 3600 * 1000 + hour_22_ms
872 }
873
874 fn next_us_equity_open_ms(&self, utc_ms: u64) -> u64 {
875 if self.us_equity_status(utc_ms) == TradingStatus::Open {
876 return utc_ms;
877 }
878 let secs = (utc_ms / 1000) as i64;
879 let dt = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
880 let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
881 let et_dt = dt + Duration::seconds(et_offset_secs);
882
883 let dow = et_dt.weekday();
884 let et_time_secs = et_dt.num_seconds_from_midnight() as u64;
885 let open_s: u64 = 9 * 3600 + 30 * 60; let days_ahead: i64 = match dow {
889 Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu => {
890 if et_time_secs < open_s {
891 0 } else {
893 1 }
895 }
896 Weekday::Fri => {
897 if et_time_secs < open_s {
898 0 } else {
900 3 }
902 }
903 Weekday::Sat => 2, Weekday::Sun => 1, };
906
907 let et_date = et_dt.date_naive();
908 let mut target_date = et_date + Duration::days(days_ahead);
909
910 loop {
912 let wd = target_date.weekday();
913 if wd == Weekday::Sat {
914 target_date += Duration::days(2);
915 continue;
916 }
917 if wd == Weekday::Sun {
918 target_date += Duration::days(1);
919 continue;
920 }
921 if is_us_market_holiday(target_date) {
922 target_date += Duration::days(1);
923 continue;
924 }
925 break;
926 }
927
928 let open_hour_utc: u32 = if is_us_dst(utc_ms) { 13 } else { 14 };
930 let approx_ms = date_to_utc_ms(target_date, open_hour_utc, 30);
931 let open_hour_utc_corrected: u32 = if is_us_dst(approx_ms) { 13 } else { 14 };
932 date_to_utc_ms(target_date, open_hour_utc_corrected, 30)
933 }
934
935 fn us_equity_status(&self, utc_ms: u64) -> TradingStatus {
936 let secs = (utc_ms / 1000) as i64;
937 let dt = Utc.timestamp_opt(secs, 0).single().unwrap_or_else(Utc::now);
938
939 let et_offset_secs: i64 = if is_us_dst(utc_ms) { -4 * 3600 } else { -5 * 3600 };
941 let et_dt = dt + Duration::seconds(et_offset_secs);
942
943 let dow = et_dt.weekday();
945 if dow == Weekday::Sat || dow == Weekday::Sun {
946 return TradingStatus::Closed;
947 }
948
949 if is_us_market_holiday(et_dt.date_naive()) {
951 return TradingStatus::Closed;
952 }
953
954 let et_time_secs = et_dt.num_seconds_from_midnight() as u64;
955 let open_s = (9 * 3600 + 30 * 60) as u64; let close_s = (16 * 3600) as u64; let pre_s = (4 * 3600) as u64; let post_s = (20 * 3600) as u64; if et_time_secs >= open_s && et_time_secs < close_s {
961 TradingStatus::Open
962 } else if (et_time_secs >= pre_s && et_time_secs < open_s)
963 || (et_time_secs >= close_s && et_time_secs < post_s)
964 {
965 TradingStatus::Extended
966 } else {
967 TradingStatus::Closed
968 }
969 }
970
971 fn forex_status(&self, utc_ms: u64) -> TradingStatus {
972 let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; let day_ms = utc_ms % (24 * 3600 * 1000);
975 let hour_22_ms = 22 * 3600 * 1000;
976
977 if day_of_week == 6 {
979 return TradingStatus::Closed;
980 }
981 if day_of_week == 0 && day_ms < hour_22_ms {
982 return TradingStatus::Closed;
983 }
984 if day_of_week == 5 && day_ms >= hour_22_ms {
986 return TradingStatus::Closed;
987 }
988 TradingStatus::Open
989 }
990}
991
992fn is_us_dst(utc_ms: u64) -> bool {
998 let secs = (utc_ms / 1000) as i64;
999 let dt = match Utc.timestamp_opt(secs, 0).single() {
1000 Some(t) => t,
1001 None => return false,
1002 };
1003 let year = dt.year();
1004
1005 let dst_start_date = nth_weekday_of_month(year, 3, Weekday::Sun, 2);
1007 let dst_start_utc_ms = date_to_utc_ms(dst_start_date, 7, 0);
1008
1009 let dst_end_date = nth_weekday_of_month(year, 11, Weekday::Sun, 1);
1011 let dst_end_utc_ms = date_to_utc_ms(dst_end_date, 6, 0);
1012
1013 utc_ms >= dst_start_utc_ms && utc_ms < dst_end_utc_ms
1014}
1015
1016fn nth_weekday_of_month(year: i32, month: u32, weekday: Weekday, n: u32) -> NaiveDate {
1018 let first = NaiveDate::from_ymd_opt(year, month, 1)
1019 .unwrap_or_else(|| NaiveDate::from_ymd_opt(year, 1, 1).unwrap());
1020 let first_wd = first.weekday();
1021 let days_ahead = (weekday.num_days_from_monday() as i32
1022 - first_wd.num_days_from_monday() as i32)
1023 .rem_euclid(7);
1024 let first_occurrence = first + Duration::days(days_ahead as i64);
1025 first_occurrence + Duration::weeks((n - 1) as i64)
1026}
1027
1028fn date_to_utc_ms(date: NaiveDate, hour: u32, minute: u32) -> u64 {
1030 let naive_dt = date
1031 .and_hms_opt(hour, minute, 0)
1032 .unwrap_or_else(|| date.and_hms_opt(0, 0, 0).unwrap());
1033 let utc_dt = Utc.from_utc_datetime(&naive_dt);
1034 (utc_dt.timestamp() as u64) * 1000
1035}
1036
1037pub fn is_us_market_holiday(date: NaiveDate) -> bool {
1046 let year = date.year();
1047
1048 let observe = |d: NaiveDate| -> NaiveDate {
1050 match d.weekday() {
1051 Weekday::Sat => d - Duration::days(1),
1052 Weekday::Sun => d + Duration::days(1),
1053 _ => d,
1054 }
1055 };
1056
1057 let make_date = |y: i32, m: u32, d: u32| {
1058 NaiveDate::from_ymd_opt(y, m, d).unwrap_or_else(|| NaiveDate::from_ymd_opt(y, 1, 1).unwrap())
1059 };
1060
1061 if date == observe(make_date(year, 1, 1)) {
1063 return true;
1064 }
1065 if date == nth_weekday_of_month(year, 1, Weekday::Mon, 3) {
1067 return true;
1068 }
1069 if date == nth_weekday_of_month(year, 2, Weekday::Mon, 3) {
1071 return true;
1072 }
1073 if date == good_friday(year) {
1075 return true;
1076 }
1077 if date.month() == 5 && date.weekday() == Weekday::Mon {
1079 let next_monday = date + Duration::days(7);
1080 if next_monday.month() != 5 {
1081 return true;
1082 }
1083 }
1084 if year >= 2022 && date == observe(make_date(year, 6, 19)) {
1086 return true;
1087 }
1088 if date == observe(make_date(year, 7, 4)) {
1090 return true;
1091 }
1092 if date == nth_weekday_of_month(year, 9, Weekday::Mon, 1) {
1094 return true;
1095 }
1096 if date == nth_weekday_of_month(year, 11, Weekday::Thu, 4) {
1098 return true;
1099 }
1100 if date == observe(make_date(year, 12, 25)) {
1102 return true;
1103 }
1104 false
1105}
1106
1107fn good_friday(year: i32) -> NaiveDate {
1109 easter_sunday(year) - Duration::days(2)
1110}
1111
1112fn easter_sunday(year: i32) -> NaiveDate {
1114 let a = year % 19;
1115 let b = year / 100;
1116 let c = year % 100;
1117 let d = b / 4;
1118 let e = b % 4;
1119 let f = (b + 8) / 25;
1120 let g = (b - f + 1) / 3;
1121 let h = (19 * a + b - d - g + 15) % 30;
1122 let i = c / 4;
1123 let k = c % 4;
1124 let l = (32 + 2 * e + 2 * i - h - k) % 7;
1125 let m = (a + 11 * h + 22 * l) / 451;
1126 let month = (h + l - 7 * m + 114) / 31;
1127 let day = ((h + l - 7 * m + 114) % 31) + 1;
1128 NaiveDate::from_ymd_opt(year, month as u32, day as u32)
1129 .unwrap_or_else(|| NaiveDate::from_ymd_opt(year, 1, 1).unwrap())
1130}
1131
1132pub fn trading_day_count(start_ms: u64, end_ms: u64) -> usize {
1138 use chrono::{Datelike, NaiveDate, TimeZone, Utc, Weekday};
1139 if end_ms <= start_ms {
1140 return 0;
1141 }
1142 let ms_to_naive = |ms: u64| {
1143 Utc.timestamp_opt((ms / 1000) as i64, 0)
1144 .single()
1145 .map(|dt| dt.date_naive())
1146 .unwrap_or_else(|| NaiveDate::from_ymd_opt(1970, 1, 1).unwrap())
1147 };
1148 let start_date = ms_to_naive(start_ms);
1149 let end_date = ms_to_naive(end_ms);
1150 let mut count = 0usize;
1151 let mut day = start_date;
1152 while day < end_date {
1153 let wd = day.weekday();
1154 if wd != Weekday::Sat && wd != Weekday::Sun && !is_us_market_holiday(day) {
1155 count += 1;
1156 }
1157 day = day.succ_opt().unwrap_or(day);
1158 }
1159 count
1160}
1161
1162pub fn is_tradeable(session: MarketSession, utc_ms: u64) -> Result<bool, StreamError> {
1164 let sa = SessionAwareness::new(session);
1165 let status = sa.status(utc_ms)?;
1166 Ok(status == TradingStatus::Open || status == TradingStatus::Extended)
1167}
1168
1169#[cfg(test)]
1170mod tests {
1171 use super::*;
1172
1173 const MON_OPEN_UTC_MS: u64 = 1704724200000;
1175 const MON_CLOSE_UTC_MS: u64 = 1704747600000;
1177 const SAT_UTC_MS: u64 = 1705147200000;
1179 const SUN_BEFORE_UTC_MS: u64 = 1704621600000;
1181
1182 const MON_SUMMER_OPEN_UTC_MS: u64 = 1720445400000;
1184
1185 fn sa(session: MarketSession) -> SessionAwareness {
1186 SessionAwareness::new(session)
1187 }
1188
1189 #[test]
1190 fn test_crypto_always_open() {
1191 let sa = sa(MarketSession::Crypto);
1192 assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
1193 assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Open);
1194 assert_eq!(sa.status(0).unwrap(), TradingStatus::Open);
1195 }
1196
1197 #[test]
1198 fn test_us_equity_open_during_market_hours_est() {
1199 let sa = sa(MarketSession::UsEquity);
1200 assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
1202 }
1203
1204 #[test]
1205 fn test_us_equity_open_during_market_hours_edt() {
1206 let sa = sa(MarketSession::UsEquity);
1207 assert_eq!(
1209 sa.status(MON_SUMMER_OPEN_UTC_MS).unwrap(),
1210 TradingStatus::Open
1211 );
1212 }
1213
1214 #[test]
1215 fn test_us_equity_closed_after_hours() {
1216 let sa = sa(MarketSession::UsEquity);
1217 let status = sa.status(MON_CLOSE_UTC_MS).unwrap();
1219 assert!(status == TradingStatus::Extended || status == TradingStatus::Closed);
1220 }
1221
1222 #[test]
1223 fn test_us_equity_closed_on_saturday() {
1224 let sa = sa(MarketSession::UsEquity);
1225 assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Closed);
1226 }
1227
1228 #[test]
1229 fn test_us_equity_premarket_extended() {
1230 let sa = sa(MarketSession::UsEquity);
1231 let pre_ms: u64 = 1704704400000;
1233 let status = sa.status(pre_ms).unwrap();
1234 assert!(status == TradingStatus::Extended || status == TradingStatus::Open);
1235 }
1236
1237 #[test]
1238 fn test_dst_transition_march() {
1239 let just_before_dst_ms = 1710053940000_u64; let just_after_dst_ms = 1710054060000_u64; assert!(!is_us_dst(just_before_dst_ms));
1245 assert!(is_us_dst(just_after_dst_ms));
1246 }
1247
1248 #[test]
1249 fn test_dst_transition_november() {
1250 let just_before_end_ms = 1730613540000_u64; let just_after_end_ms = 1730613660000_u64; assert!(is_us_dst(just_before_end_ms));
1256 assert!(!is_us_dst(just_after_end_ms));
1257 }
1258
1259 #[test]
1260 fn test_forex_open_on_monday() {
1261 let sa = sa(MarketSession::Forex);
1262 assert_eq!(sa.status(MON_OPEN_UTC_MS).unwrap(), TradingStatus::Open);
1263 }
1264
1265 #[test]
1266 fn test_forex_closed_on_saturday() {
1267 let sa = sa(MarketSession::Forex);
1268 assert_eq!(sa.status(SAT_UTC_MS).unwrap(), TradingStatus::Closed);
1269 }
1270
1271 #[test]
1272 fn test_forex_closed_sunday_before_22_utc() {
1273 let sa = sa(MarketSession::Forex);
1274 assert_eq!(sa.status(SUN_BEFORE_UTC_MS).unwrap(), TradingStatus::Closed);
1275 }
1276
1277 #[test]
1278 fn test_is_tradeable_crypto_always_true() {
1279 assert!(is_tradeable(MarketSession::Crypto, SAT_UTC_MS).unwrap());
1280 }
1281
1282 #[test]
1283 fn test_is_tradeable_equity_open() {
1284 assert!(is_tradeable(MarketSession::UsEquity, MON_OPEN_UTC_MS).unwrap());
1285 }
1286
1287 #[test]
1288 fn test_is_tradeable_equity_weekend_false() {
1289 assert!(!is_tradeable(MarketSession::UsEquity, SAT_UTC_MS).unwrap());
1290 }
1291
1292 #[test]
1293 fn test_session_accessor() {
1294 let sa = sa(MarketSession::Crypto);
1295 assert_eq!(sa.session(), MarketSession::Crypto);
1296 }
1297
1298 #[test]
1299 fn test_market_session_equality() {
1300 assert_eq!(MarketSession::Crypto, MarketSession::Crypto);
1301 assert_ne!(MarketSession::Crypto, MarketSession::Forex);
1302 }
1303
1304 #[test]
1305 fn test_trading_status_equality() {
1306 assert_eq!(TradingStatus::Open, TradingStatus::Open);
1307 assert_ne!(TradingStatus::Open, TradingStatus::Closed);
1308 }
1309
1310 #[test]
1311 fn test_nth_weekday_of_month_second_sunday_march_2024() {
1312 let date = nth_weekday_of_month(2024, 3, Weekday::Sun, 2);
1314 assert_eq!(date.month(), 3);
1315 assert_eq!(date.day(), 10);
1316 }
1317
1318 #[test]
1319 fn test_nth_weekday_of_month_first_sunday_november_2024() {
1320 let date = nth_weekday_of_month(2024, 11, Weekday::Sun, 1);
1322 assert_eq!(date.month(), 11);
1323 assert_eq!(date.day(), 3);
1324 }
1325
1326 #[test]
1329 fn test_next_open_crypto_is_always_now() {
1330 let sa = sa(MarketSession::Crypto);
1331 assert_eq!(sa.next_open_ms(SAT_UTC_MS), SAT_UTC_MS);
1332 assert_eq!(sa.next_open_ms(MON_OPEN_UTC_MS), MON_OPEN_UTC_MS);
1333 }
1334
1335 #[test]
1336 fn test_next_open_equity_already_open_returns_same() {
1337 let sa = sa(MarketSession::UsEquity);
1339 let next = sa.next_open_ms(MON_OPEN_UTC_MS);
1340 assert_eq!(next, MON_OPEN_UTC_MS);
1341 }
1342
1343 #[test]
1344 fn test_next_open_equity_saturday_returns_monday_open() {
1345 let sa = sa(MarketSession::UsEquity);
1348 let next = sa.next_open_ms(SAT_UTC_MS);
1349 assert!(next > SAT_UTC_MS, "next open must be after Saturday");
1351 assert_eq!(
1352 sa.status(next).unwrap(),
1353 TradingStatus::Open,
1354 "next_open_ms must return a time when market is Open"
1355 );
1356 }
1357
1358 #[test]
1359 fn test_next_open_equity_sunday_returns_monday_open() {
1360 let sa = sa(MarketSession::UsEquity);
1362 let next = sa.next_open_ms(SUN_BEFORE_UTC_MS);
1363 assert!(next > SUN_BEFORE_UTC_MS);
1364 assert_eq!(sa.status(next).unwrap(), TradingStatus::Open);
1365 }
1366
1367 #[test]
1368 fn test_next_open_forex_already_open_returns_same() {
1369 let sa = sa(MarketSession::Forex);
1370 assert_eq!(sa.next_open_ms(MON_OPEN_UTC_MS), MON_OPEN_UTC_MS);
1371 }
1372
1373 #[test]
1374 fn test_next_open_forex_saturday_returns_sunday_22_utc() {
1375 let sa = sa(MarketSession::Forex);
1377 let next = sa.next_open_ms(SAT_UTC_MS);
1378 assert!(next > SAT_UTC_MS);
1379 assert_eq!(sa.status(next).unwrap(), TradingStatus::Open);
1380 let expected_hour_ms = 22 * 3600 * 1000;
1382 assert_eq!(next % (24 * 3600 * 1000), expected_hour_ms);
1383 }
1384
1385 #[test]
1386 fn test_next_open_forex_sunday_before_22_returns_same_day_22() {
1387 let sa = sa(MarketSession::Forex);
1389 let next = sa.next_open_ms(SUN_BEFORE_UTC_MS);
1390 let day_ms = SUN_BEFORE_UTC_MS - (SUN_BEFORE_UTC_MS % (24 * 3600 * 1000));
1391 let expected = day_ms + 22 * 3600 * 1000;
1392 assert_eq!(next, expected);
1393 }
1394
1395 #[test]
1398 fn test_holiday_new_years_day_2024() {
1399 let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
1401 assert!(is_us_market_holiday(date), "New Year's Day should be a holiday");
1402 }
1403
1404 #[test]
1405 fn test_holiday_new_years_observed_when_on_sunday() {
1406 let observed = NaiveDate::from_ymd_opt(2023, 1, 2).unwrap();
1408 assert!(is_us_market_holiday(observed), "Observed New Year's should be a holiday");
1409 let actual = NaiveDate::from_ymd_opt(2023, 1, 1).unwrap();
1410 assert!(!is_us_market_holiday(actual), "Sunday itself is not the observed holiday");
1411 }
1412
1413 #[test]
1414 fn test_holiday_mlk_day_2024() {
1415 let date = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
1417 assert!(is_us_market_holiday(date), "MLK Day should be a holiday");
1418 }
1419
1420 #[test]
1421 fn test_holiday_good_friday_2024() {
1422 let date = NaiveDate::from_ymd_opt(2024, 3, 29).unwrap();
1424 assert!(is_us_market_holiday(date), "Good Friday 2024 should be a holiday");
1425 }
1426
1427 #[test]
1428 fn test_holiday_memorial_day_2024() {
1429 let date = NaiveDate::from_ymd_opt(2024, 5, 27).unwrap();
1431 assert!(is_us_market_holiday(date), "Memorial Day should be a holiday");
1432 }
1433
1434 #[test]
1435 fn test_holiday_independence_day_2024() {
1436 let date = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
1438 assert!(is_us_market_holiday(date), "Independence Day should be a holiday");
1439 }
1440
1441 #[test]
1442 fn test_holiday_labor_day_2024() {
1443 let date = NaiveDate::from_ymd_opt(2024, 9, 2).unwrap();
1445 assert!(is_us_market_holiday(date), "Labor Day should be a holiday");
1446 }
1447
1448 #[test]
1449 fn test_holiday_thanksgiving_2024() {
1450 let date = NaiveDate::from_ymd_opt(2024, 11, 28).unwrap();
1452 assert!(is_us_market_holiday(date), "Thanksgiving should be a holiday");
1453 }
1454
1455 #[test]
1456 fn test_holiday_christmas_2024() {
1457 let date = NaiveDate::from_ymd_opt(2024, 12, 25).unwrap();
1459 assert!(is_us_market_holiday(date), "Christmas should be a holiday");
1460 }
1461
1462 #[test]
1463 fn test_holiday_regular_monday_is_not_holiday() {
1464 let date = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
1466 assert!(!is_us_market_holiday(date), "Regular Monday should not be a holiday");
1467 }
1468
1469 #[test]
1470 fn test_holiday_market_closed_on_christmas_2024() {
1471 let christmas_open_utc_ms = date_to_utc_ms(
1474 NaiveDate::from_ymd_opt(2024, 12, 25).unwrap(),
1475 14,
1476 30,
1477 );
1478 let sa = sa(MarketSession::UsEquity);
1479 assert_eq!(
1480 sa.status(christmas_open_utc_ms).unwrap(),
1481 TradingStatus::Closed,
1482 "Market should be closed on Christmas"
1483 );
1484 }
1485
1486 #[test]
1487 fn test_easter_sunday_2024() {
1488 assert_eq!(easter_sunday(2024), NaiveDate::from_ymd_opt(2024, 3, 31).unwrap());
1489 }
1490
1491 #[test]
1492 fn test_easter_sunday_2025() {
1493 assert_eq!(easter_sunday(2025), NaiveDate::from_ymd_opt(2025, 4, 20).unwrap());
1494 }
1495
1496 #[test]
1499 fn test_time_until_open_crypto_is_zero() {
1500 let sa = sa(MarketSession::Crypto);
1501 assert_eq!(sa.time_until_open_ms(SAT_UTC_MS), 0);
1502 assert_eq!(sa.time_until_open_ms(MON_OPEN_UTC_MS), 0);
1503 }
1504
1505 #[test]
1506 fn test_time_until_open_equity_already_open_is_zero() {
1507 let sa = sa(MarketSession::UsEquity);
1508 assert_eq!(sa.time_until_open_ms(MON_OPEN_UTC_MS), 0);
1509 }
1510
1511 #[test]
1512 fn test_time_until_open_equity_saturday_is_positive() {
1513 let sa = sa(MarketSession::UsEquity);
1514 assert!(sa.time_until_open_ms(SAT_UTC_MS) > 0);
1515 }
1516
1517 #[test]
1518 fn test_time_until_close_crypto_is_max() {
1519 let sa = sa(MarketSession::Crypto);
1520 assert_eq!(sa.time_until_close_ms(MON_OPEN_UTC_MS), u64::MAX);
1521 }
1522
1523 #[test]
1524 fn test_time_until_close_equity_already_closed_is_zero() {
1525 let sa = sa(MarketSession::UsEquity);
1526 assert_eq!(sa.time_until_close_ms(SAT_UTC_MS), 0);
1527 }
1528
1529 #[test]
1530 fn test_time_until_close_equity_open_is_positive() {
1531 let sa = sa(MarketSession::UsEquity);
1532 assert!(sa.time_until_close_ms(MON_OPEN_UTC_MS) > 0);
1533 }
1534
1535 #[test]
1538 fn test_is_open_crypto_always_true() {
1539 let sa = sa(MarketSession::Crypto);
1540 assert!(sa.is_open(SAT_UTC_MS));
1541 assert!(sa.is_open(0));
1542 }
1543
1544 #[test]
1545 fn test_is_open_equity_during_market_hours() {
1546 let sa = sa(MarketSession::UsEquity);
1547 assert!(sa.is_open(MON_OPEN_UTC_MS));
1548 }
1549
1550 #[test]
1551 fn test_is_open_equity_on_weekend_false() {
1552 let sa = sa(MarketSession::UsEquity);
1553 assert!(!sa.is_open(SAT_UTC_MS));
1554 }
1555
1556 #[test]
1557 fn test_is_open_forex_on_monday_true() {
1558 let sa = sa(MarketSession::Forex);
1559 assert!(sa.is_open(MON_OPEN_UTC_MS));
1560 }
1561
1562 #[test]
1563 fn test_is_open_forex_on_saturday_false() {
1564 let sa = sa(MarketSession::Forex);
1565 assert!(!sa.is_open(SAT_UTC_MS));
1566 }
1567
1568 #[test]
1571 fn test_next_close_crypto_is_max() {
1572 let sa = sa(MarketSession::Crypto);
1573 assert_eq!(sa.next_close_ms(MON_OPEN_UTC_MS), u64::MAX);
1574 assert_eq!(sa.next_close_ms(SAT_UTC_MS), u64::MAX);
1575 }
1576
1577 #[test]
1578 fn test_next_close_equity_already_closed_returns_same() {
1579 let sa = sa(MarketSession::UsEquity);
1581 assert_eq!(sa.next_close_ms(SAT_UTC_MS), SAT_UTC_MS);
1582 }
1583
1584 #[test]
1585 fn test_next_close_equity_open_est_returns_20_00_et() {
1586 let sa = sa(MarketSession::UsEquity);
1589 let close = sa.next_close_ms(MON_OPEN_UTC_MS);
1590 assert!(close > MON_OPEN_UTC_MS);
1591 assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1592 assert_eq!(close, 1704762000000);
1594 }
1595
1596 #[test]
1597 fn test_next_close_equity_open_edt_returns_midnight_utc() {
1598 let sa = sa(MarketSession::UsEquity);
1601 let close = sa.next_close_ms(MON_SUMMER_OPEN_UTC_MS);
1602 assert!(close > MON_SUMMER_OPEN_UTC_MS);
1603 assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1604 assert_eq!(close, 1720483200000);
1606 }
1607
1608 #[test]
1609 fn test_next_close_equity_extended_returns_20_00_et() {
1610 let sa = sa(MarketSession::UsEquity);
1613 let close = sa.next_close_ms(MON_CLOSE_UTC_MS);
1614 assert!(close > MON_CLOSE_UTC_MS);
1615 assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1616 assert_eq!(close, 1704762000000);
1617 }
1618
1619 #[test]
1620 fn test_next_close_forex_already_closed_returns_same() {
1621 let sa = sa(MarketSession::Forex);
1623 assert_eq!(sa.next_close_ms(SAT_UTC_MS), SAT_UTC_MS);
1624 }
1625
1626 #[test]
1627 fn test_next_close_forex_open_monday_returns_friday_22_utc() {
1628 let sa = sa(MarketSession::Forex);
1631 let close = sa.next_close_ms(MON_OPEN_UTC_MS);
1632 assert!(close > MON_OPEN_UTC_MS);
1633 assert_eq!(sa.status(close).unwrap(), TradingStatus::Closed);
1634 assert_eq!(close, 1705096800000);
1635 }
1636
1637 #[test]
1640 fn test_session_duration_us_equity_is_6_5_hours() {
1641 assert_eq!(
1643 MarketSession::UsEquity.session_duration_ms(),
1644 6 * 3_600_000 + 30 * 60_000
1645 );
1646 }
1647
1648 #[test]
1649 fn test_session_duration_forex_is_120_hours() {
1650 assert_eq!(MarketSession::Forex.session_duration_ms(), 5 * 24 * 3_600_000);
1651 }
1652
1653 #[test]
1654 fn test_session_duration_crypto_is_max() {
1655 assert_eq!(MarketSession::Crypto.session_duration_ms(), u64::MAX);
1656 }
1657
1658 #[test]
1661 fn test_is_extended_crypto_is_never_extended() {
1662 let sa = sa(MarketSession::Crypto);
1663 assert!(!sa.is_extended(MON_OPEN_UTC_MS));
1665 }
1666
1667 #[test]
1668 fn test_is_extended_equity_during_extended_hours() {
1669 let seven_am_est_ms = 1704715200_000u64;
1673 let sa = sa(MarketSession::UsEquity);
1674 assert_eq!(sa.status(seven_am_est_ms).unwrap(), TradingStatus::Extended);
1675 assert!(sa.is_extended(seven_am_est_ms));
1676 }
1677
1678 #[test]
1679 fn test_is_extended_equity_during_open_is_false() {
1680 let sa = sa(MarketSession::UsEquity);
1681 assert!(!sa.is_extended(MON_OPEN_UTC_MS));
1683 }
1684
1685 #[test]
1688 fn test_session_progress_none_when_closed() {
1689 let sa = sa(MarketSession::UsEquity);
1690 assert!(sa.session_progress(SAT_UTC_MS).is_none());
1692 }
1693
1694 #[test]
1695 fn test_session_progress_none_for_crypto() {
1696 let sa = sa(MarketSession::Crypto);
1697 assert!(sa.session_progress(MON_OPEN_UTC_MS).is_none());
1698 }
1699
1700 #[test]
1701 fn test_session_progress_at_open_is_zero() {
1702 let sa = sa(MarketSession::UsEquity);
1703 let progress = sa.session_progress(MON_OPEN_UTC_MS).unwrap();
1705 assert!(progress.abs() < 1e-6, "expected ~0.0 got {progress}");
1706 }
1707
1708 #[test]
1709 fn test_session_progress_midway() {
1710 let sa = sa(MarketSession::UsEquity);
1711 let mid_ms = MON_OPEN_UTC_MS + 11_700_000;
1714 let progress = sa.session_progress(mid_ms).unwrap();
1715 assert!((progress - 0.5).abs() < 1e-6, "expected ~0.5 got {progress}");
1716 }
1717
1718 #[test]
1719 fn test_session_progress_in_range_zero_to_one() {
1720 let sa = sa(MarketSession::UsEquity);
1721 let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1723 let progress = sa.session_progress(one_hour_in).unwrap();
1724 assert!(progress > 0.0 && progress < 1.0, "expected (0,1) got {progress}");
1725 }
1726
1727 #[test]
1730 fn test_is_closed_crypto_is_never_closed() {
1731 let sa = sa(MarketSession::Crypto);
1732 assert!(!sa.is_closed(MON_OPEN_UTC_MS));
1733 assert!(!sa.is_closed(SAT_UTC_MS));
1734 }
1735
1736 #[test]
1737 fn test_is_closed_equity_on_weekend() {
1738 let sa = sa(MarketSession::UsEquity);
1739 assert!(sa.is_closed(SAT_UTC_MS));
1740 }
1741
1742 #[test]
1743 fn test_is_closed_equity_during_open_is_false() {
1744 let sa = sa(MarketSession::UsEquity);
1745 assert!(!sa.is_closed(MON_OPEN_UTC_MS));
1746 }
1747
1748 #[test]
1751 fn test_is_market_hours_crypto_always_true() {
1752 let sa = sa(MarketSession::Crypto);
1753 assert!(sa.is_market_hours(MON_OPEN_UTC_MS));
1754 assert!(sa.is_market_hours(SAT_UTC_MS));
1755 }
1756
1757 #[test]
1758 fn test_is_market_hours_equity_open_is_true() {
1759 let sa = sa(MarketSession::UsEquity);
1760 assert!(sa.is_market_hours(MON_OPEN_UTC_MS));
1761 }
1762
1763 #[test]
1764 fn test_is_market_hours_equity_extended_is_true() {
1765 let seven_am_est_ms = 1704715200_000u64;
1767 let sa = sa(MarketSession::UsEquity);
1768 assert!(sa.is_market_hours(seven_am_est_ms));
1769 }
1770
1771 #[test]
1772 fn test_is_market_hours_equity_closed_is_false() {
1773 let sa = sa(MarketSession::UsEquity);
1774 assert!(!sa.is_market_hours(SAT_UTC_MS));
1775 }
1776
1777 #[test]
1780 fn test_us_equity_has_extended_hours() {
1781 assert!(MarketSession::UsEquity.has_extended_hours());
1782 }
1783
1784 #[test]
1785 fn test_crypto_has_no_extended_hours() {
1786 assert!(!MarketSession::Crypto.has_extended_hours());
1787 }
1788
1789 #[test]
1790 fn test_forex_has_no_extended_hours() {
1791 assert!(!MarketSession::Forex.has_extended_hours());
1792 }
1793
1794 #[test]
1797 fn test_time_in_session_ms_none_when_closed() {
1798 let sa = sa(MarketSession::UsEquity);
1799 assert!(sa.time_in_session_ms(SAT_UTC_MS).is_none());
1800 }
1801
1802 #[test]
1803 fn test_time_in_session_ms_none_for_crypto() {
1804 let sa = sa(MarketSession::Crypto);
1805 assert!(sa.time_in_session_ms(MON_OPEN_UTC_MS).is_none());
1806 }
1807
1808 #[test]
1809 fn test_time_in_session_ms_zero_at_open() {
1810 let sa = sa(MarketSession::UsEquity);
1811 assert_eq!(sa.time_in_session_ms(MON_OPEN_UTC_MS).unwrap(), 0);
1812 }
1813
1814 #[test]
1815 fn test_time_in_session_ms_one_hour_in() {
1816 let sa = sa(MarketSession::UsEquity);
1817 let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1818 assert_eq!(sa.time_in_session_ms(one_hour_in).unwrap(), 3_600_000);
1819 }
1820
1821 #[test]
1824 fn test_minutes_until_close_crypto_is_max() {
1825 let sa = sa(MarketSession::Crypto);
1826 assert_eq!(sa.minutes_until_close(MON_OPEN_UTC_MS), u64::MAX);
1827 }
1828
1829 #[test]
1830 fn test_minutes_until_close_equity_already_closed() {
1831 let sa = sa(MarketSession::UsEquity);
1832 assert_eq!(sa.minutes_until_close(SAT_UTC_MS), 0);
1834 }
1835
1836 #[test]
1837 fn test_minutes_until_close_equity_open_positive() {
1838 let sa = sa(MarketSession::UsEquity);
1839 let mins = sa.minutes_until_close(MON_OPEN_UTC_MS);
1840 assert!(mins > 0, "expected > 0 minutes until close, got {mins}");
1841 }
1842
1843 #[test]
1844 fn test_remaining_session_ms_complements_elapsed() {
1845 let sa = sa(MarketSession::UsEquity);
1846 let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1847 let elapsed = sa.time_in_session_ms(one_hour_in).unwrap();
1848 let remaining = sa.remaining_session_ms(one_hour_in).unwrap();
1849 let duration_ms = MarketSession::UsEquity.session_duration_ms();
1850 assert_eq!(elapsed + remaining, duration_ms);
1851 }
1852
1853 #[test]
1854 fn test_remaining_session_ms_closed_returns_none() {
1855 let sa = sa(MarketSession::UsEquity);
1856 assert!(sa.remaining_session_ms(SAT_UTC_MS).is_none());
1857 }
1858
1859 #[test]
1862 fn test_is_weekend_saturday_is_weekend() {
1863 assert!(SessionAwareness::is_weekend(SAT_UTC_MS));
1865 }
1866
1867 #[test]
1868 fn test_is_weekend_sunday_is_weekend() {
1869 assert!(SessionAwareness::is_weekend(SUN_BEFORE_UTC_MS));
1871 }
1872
1873 #[test]
1874 fn test_is_weekend_monday_is_not_weekend() {
1875 assert!(!SessionAwareness::is_weekend(MON_OPEN_UTC_MS));
1877 }
1878
1879 #[test]
1882 fn test_minutes_since_open_zero_at_open() {
1883 let sa = sa(MarketSession::UsEquity);
1884 assert_eq!(sa.minutes_since_open(MON_OPEN_UTC_MS), 0);
1885 }
1886
1887 #[test]
1888 fn test_minutes_since_open_one_hour_in() {
1889 let sa = sa(MarketSession::UsEquity);
1890 let one_hour_in = MON_OPEN_UTC_MS + 3_600_000;
1891 assert_eq!(sa.minutes_since_open(one_hour_in), 60);
1892 }
1893
1894 #[test]
1895 fn test_minutes_since_open_zero_when_closed() {
1896 let sa = sa(MarketSession::UsEquity);
1897 assert_eq!(sa.minutes_since_open(SAT_UTC_MS), 0);
1898 }
1899
1900 #[test]
1903 fn test_is_regular_session_true_during_open() {
1904 let sa = sa(MarketSession::UsEquity);
1905 assert!(sa.is_regular_session(MON_OPEN_UTC_MS));
1906 }
1907
1908 #[test]
1909 fn test_is_regular_session_false_on_weekend() {
1910 let sa = sa(MarketSession::UsEquity);
1911 assert!(!sa.is_regular_session(SAT_UTC_MS));
1912 }
1913
1914 #[test]
1915 fn test_is_regular_session_false_before_open() {
1916 let sa = sa(MarketSession::UsEquity);
1917 assert!(!sa.is_regular_session(SUN_BEFORE_UTC_MS));
1919 }
1920
1921 #[test]
1924 fn test_fraction_of_day_elapsed_midnight_is_zero() {
1925 let sa = sa(MarketSession::Crypto);
1926 let midnight_ms: u64 = 24 * 60 * 60 * 1000; assert!((sa.fraction_of_day_elapsed(midnight_ms) - 0.0).abs() < 1e-12);
1929 }
1930
1931 #[test]
1932 fn test_fraction_of_day_elapsed_noon_is_half() {
1933 let sa = sa(MarketSession::Crypto);
1934 let noon_offset_ms: u64 = 12 * 60 * 60 * 1000;
1936 let frac = sa.fraction_of_day_elapsed(noon_offset_ms);
1937 assert!((frac - 0.5).abs() < 1e-10);
1938 }
1939
1940 #[test]
1941 fn test_fraction_of_day_elapsed_range_zero_to_one() {
1942 let sa = sa(MarketSession::Crypto);
1943 for ms in [0u64, 1_000, 43_200_000, 86_399_999] {
1944 let frac = sa.fraction_of_day_elapsed(ms);
1945 assert!((0.0..1.0).contains(&frac));
1946 }
1947 }
1948
1949 #[test]
1952 fn test_remaining_until_close_ms_some_when_open() {
1953 let sa = sa(MarketSession::UsEquity);
1954 let remaining = sa.remaining_until_close_ms(MON_OPEN_UTC_MS).unwrap();
1956 assert!(remaining > 0, "remaining should be positive when session is open");
1957 assert!(remaining < 24 * 60 * 60 * 1000, "remaining should be less than 24h");
1958 }
1959
1960 #[test]
1961 fn test_remaining_until_close_ms_none_when_closed() {
1962 let sa = sa(MarketSession::UsEquity);
1963 assert!(sa.remaining_until_close_ms(SAT_UTC_MS).is_none());
1964 }
1965
1966 #[test]
1967 fn test_remaining_until_close_ms_decreases_as_time_advances() {
1968 let sa = sa(MarketSession::UsEquity);
1969 let t1 = sa.remaining_until_close_ms(MON_OPEN_UTC_MS).unwrap();
1970 let t2 = sa.remaining_until_close_ms(MON_OPEN_UTC_MS + 60_000).unwrap();
1971 assert!(t1 > t2);
1972 }
1973
1974 #[test]
1977 fn test_is_last_trading_hour_true_within_last_hour() {
1978 let sa = sa(MarketSession::UsEquity);
1979 let thirty_before_close = MON_CLOSE_UTC_MS - 30 * 60 * 1_000;
1981 assert!(sa.is_last_trading_hour(thirty_before_close));
1982 }
1983
1984 #[test]
1985 fn test_is_last_trading_hour_false_at_open() {
1986 let sa = sa(MarketSession::UsEquity);
1987 assert!(!sa.is_last_trading_hour(MON_OPEN_UTC_MS));
1989 }
1990
1991 #[test]
1992 fn test_is_last_trading_hour_false_when_closed() {
1993 let sa = sa(MarketSession::UsEquity);
1994 assert!(!sa.is_last_trading_hour(SAT_UTC_MS));
1995 }
1996
1997 #[test]
2000 fn test_is_pre_open_true_in_pre_market_window() {
2001 let sa = sa(MarketSession::UsEquity);
2002 assert!(sa.is_pre_open(MON_OPEN_UTC_MS - 60_000));
2003 }
2004
2005 #[test]
2006 fn test_is_pre_open_false_during_regular_session() {
2007 let sa = sa(MarketSession::UsEquity);
2008 assert!(!sa.is_pre_open(MON_OPEN_UTC_MS));
2009 }
2010
2011 #[test]
2012 fn test_is_pre_open_false_when_closed() {
2013 let sa = sa(MarketSession::UsEquity);
2014 assert!(!sa.is_pre_open(SAT_UTC_MS));
2015 }
2016
2017 #[test]
2018 fn test_day_fraction_remaining_plus_elapsed_equals_one() {
2019 let sa = sa(MarketSession::UsEquity);
2020 let elapsed = sa.fraction_of_day_elapsed(MON_OPEN_UTC_MS);
2021 let remaining = sa.day_fraction_remaining(MON_OPEN_UTC_MS);
2022 assert!((elapsed + remaining - 1.0).abs() < 1e-12);
2023 }
2024
2025 #[test]
2026 fn test_day_fraction_remaining_one_at_midnight() {
2027 let sa = sa(MarketSession::UsEquity);
2028 assert!((sa.day_fraction_remaining(0) - 1.0).abs() < 1e-12);
2030 }
2031
2032 #[test]
2035 fn test_is_near_close_true_within_margin() {
2036 let sa = sa(MarketSession::UsEquity);
2037 let fifteen_before = MON_CLOSE_UTC_MS - 15 * 60_000;
2039 assert!(sa.is_near_close(fifteen_before, 30 * 60_000));
2040 }
2041
2042 #[test]
2043 fn test_is_near_close_false_outside_margin() {
2044 let sa = sa(MarketSession::UsEquity);
2045 let two_hours_before = MON_CLOSE_UTC_MS - 2 * 3_600_000;
2047 assert!(!sa.is_near_close(two_hours_before, 30 * 60_000));
2048 }
2049
2050 #[test]
2051 fn test_is_near_close_false_when_closed() {
2052 let sa = sa(MarketSession::UsEquity);
2053 assert!(!sa.is_near_close(SAT_UTC_MS, 3_600_000));
2054 }
2055
2056 #[test]
2057 fn test_open_duration_ms_us_equity() {
2058 let sa = sa(MarketSession::UsEquity);
2059 assert_eq!(sa.open_duration_ms(), 6 * 3_600_000 + 30 * 60_000);
2061 }
2062
2063 #[test]
2064 fn test_open_duration_ms_crypto() {
2065 let sa = sa(MarketSession::Crypto);
2066 assert_eq!(sa.open_duration_ms(), u64::MAX);
2067 }
2068
2069 #[test]
2072 fn test_is_overnight_true_when_closed_on_weekday() {
2073 let equity_sa = sa(MarketSession::UsEquity);
2074 let _tue_07h_utc = MON_OPEN_UTC_MS + 24 * 3_600_000 - 7 * 3_600_000 + 7 * 3_600_000 - (14 * 3_600_000 + 30 * 60_000) + 7 * 3_600_000;
2083 let _ = equity_sa;
2087 let sa_crypto = sa(MarketSession::Crypto);
2088 assert!(!sa_crypto.is_overnight(MON_OPEN_UTC_MS));
2089 }
2090
2091 #[test]
2092 fn test_is_overnight_false_during_regular_session() {
2093 let sa = sa(MarketSession::UsEquity);
2094 assert!(!sa.is_overnight(MON_OPEN_UTC_MS));
2095 }
2096
2097 #[test]
2098 fn test_is_overnight_false_for_crypto() {
2099 let sa = sa(MarketSession::Crypto);
2100 assert!(!sa.is_overnight(SAT_UTC_MS));
2101 }
2102
2103 #[test]
2104 fn test_minutes_to_next_open_zero_when_already_open() {
2105 let sa = sa(MarketSession::UsEquity);
2106 assert_eq!(sa.minutes_to_next_open(MON_OPEN_UTC_MS), 0.0);
2107 }
2108
2109 #[test]
2110 fn test_minutes_to_next_open_positive_when_closed() {
2111 let sa = sa(MarketSession::UsEquity);
2112 let mins = sa.minutes_to_next_open(SAT_UTC_MS);
2113 assert!(mins > 0.0);
2114 }
2115
2116 #[test]
2118 fn test_session_progress_pct_zero_when_closed() {
2119 let sa = sa(MarketSession::UsEquity);
2120 assert_eq!(sa.session_progress_pct(SAT_UTC_MS), 0.0);
2121 }
2122
2123 #[test]
2124 fn test_session_progress_pct_positive_when_open() {
2125 let sa = sa(MarketSession::UsEquity);
2126 let pct = sa.session_progress_pct(MON_OPEN_UTC_MS + 30 * 60_000);
2128 assert!(pct > 0.0 && pct < 100.0, "expected 0-100, got {pct}");
2129 }
2130
2131 #[test]
2133 fn test_is_last_minute_true_within_last_60s() {
2134 let sa = sa(MarketSession::UsEquity);
2135 assert!(sa.is_last_minute(MON_CLOSE_UTC_MS - 30_000));
2136 }
2137
2138 #[test]
2139 fn test_is_last_minute_false_when_more_than_60s_remain() {
2140 let sa = sa(MarketSession::UsEquity);
2141 assert!(!sa.is_last_minute(MON_CLOSE_UTC_MS - 120_000));
2142 }
2143
2144 #[test]
2145 fn test_is_last_minute_false_when_closed() {
2146 let sa = sa(MarketSession::UsEquity);
2147 assert!(!sa.is_last_minute(SAT_UTC_MS));
2148 }
2149
2150 #[test]
2152 fn test_minutes_since_close_zero_when_open() {
2153 let sa = sa(MarketSession::UsEquity);
2154 assert_eq!(sa.minutes_since_close(MON_OPEN_UTC_MS + 30 * 60_000), 0.0);
2155 }
2156
2157 #[test]
2158 fn test_minutes_since_close_positive_when_closed() {
2159 let sa = sa(MarketSession::UsEquity);
2160 let mins = sa.minutes_since_close(SAT_UTC_MS);
2161 assert!(mins > 0.0, "expected positive value when closed");
2162 }
2163
2164 #[test]
2166 fn test_is_opening_bell_minute_true_at_open() {
2167 let sa = sa(MarketSession::UsEquity);
2168 assert!(sa.is_opening_bell_minute(MON_OPEN_UTC_MS + 30_000));
2169 }
2170
2171 #[test]
2172 fn test_is_opening_bell_minute_false_after_first_minute() {
2173 let sa = sa(MarketSession::UsEquity);
2174 assert!(!sa.is_opening_bell_minute(MON_OPEN_UTC_MS + 90_000));
2175 }
2176
2177 #[test]
2178 fn test_is_opening_bell_minute_false_when_closed() {
2179 let sa = sa(MarketSession::UsEquity);
2180 assert!(!sa.is_opening_bell_minute(SAT_UTC_MS));
2181 }
2182
2183 #[test]
2186 fn test_is_extended_hours_true_in_pre_market() {
2187 let sa = sa(MarketSession::UsEquity);
2188 assert!(sa.is_extended_hours(MON_OPEN_UTC_MS - 60_000));
2190 }
2191
2192 #[test]
2193 fn test_is_extended_hours_true_in_after_hours() {
2194 let sa = sa(MarketSession::UsEquity);
2195 assert!(sa.is_extended_hours(MON_CLOSE_UTC_MS + 60_000));
2197 }
2198
2199 #[test]
2200 fn test_is_extended_hours_false_during_regular_session() {
2201 let sa = sa(MarketSession::UsEquity);
2202 assert!(!sa.is_extended_hours(MON_OPEN_UTC_MS));
2203 }
2204
2205 #[test]
2208 fn test_is_opening_range_true_at_open() {
2209 let sa = sa(MarketSession::UsEquity);
2210 assert!(sa.is_opening_range(MON_OPEN_UTC_MS));
2212 }
2213
2214 #[test]
2215 fn test_is_opening_range_true_at_15_minutes() {
2216 let sa = sa(MarketSession::UsEquity);
2217 assert!(sa.is_opening_range(MON_OPEN_UTC_MS + 900_000));
2219 }
2220
2221 #[test]
2222 fn test_is_opening_range_false_after_30_minutes() {
2223 let sa = sa(MarketSession::UsEquity);
2224 assert!(!sa.is_opening_range(MON_OPEN_UTC_MS + 31 * 60_000));
2226 }
2227
2228 #[test]
2229 fn test_is_opening_range_false_when_closed() {
2230 let sa = sa(MarketSession::UsEquity);
2231 assert!(!sa.is_opening_range(SAT_UTC_MS));
2232 }
2233
2234 #[test]
2237 fn test_is_mid_session_true_at_halfway_point() {
2238 let sa = sa(MarketSession::UsEquity);
2239 assert!(sa.is_mid_session(MON_OPEN_UTC_MS + 3 * 3_600_000));
2241 }
2242
2243 #[test]
2244 fn test_is_mid_session_false_in_opening_range() {
2245 let sa = sa(MarketSession::UsEquity);
2246 assert!(!sa.is_mid_session(MON_OPEN_UTC_MS + 5 * 60_000));
2248 }
2249
2250 #[test]
2251 fn test_is_mid_session_false_when_closed() {
2252 let sa = sa(MarketSession::UsEquity);
2253 assert!(!sa.is_mid_session(SAT_UTC_MS));
2254 }
2255
2256 #[test]
2259 fn test_is_first_quarter_true_at_open() {
2260 let sa = sa(MarketSession::UsEquity);
2261 assert!(sa.is_first_quarter(MON_OPEN_UTC_MS));
2263 }
2264
2265 #[test]
2266 fn test_is_first_quarter_false_at_midpoint() {
2267 let sa = sa(MarketSession::UsEquity);
2268 assert!(!sa.is_first_quarter(MON_OPEN_UTC_MS + 3 * 3_600_000));
2270 }
2271
2272 #[test]
2273 fn test_is_first_quarter_false_when_closed() {
2274 let sa = sa(MarketSession::UsEquity);
2275 assert!(!sa.is_first_quarter(SAT_UTC_MS));
2276 }
2277
2278 #[test]
2279 fn test_is_last_quarter_true_near_close() {
2280 let sa = sa(MarketSession::UsEquity);
2281 assert!(sa.is_last_quarter(MON_OPEN_UTC_MS + 18_720_000));
2283 }
2284
2285 #[test]
2286 fn test_is_last_quarter_false_at_open() {
2287 let sa = sa(MarketSession::UsEquity);
2288 assert!(!sa.is_last_quarter(MON_OPEN_UTC_MS));
2289 }
2290
2291 #[test]
2292 fn test_is_last_quarter_false_when_closed() {
2293 let sa = sa(MarketSession::UsEquity);
2294 assert!(!sa.is_last_quarter(SAT_UTC_MS));
2295 }
2296
2297 #[test]
2300 fn test_minutes_elapsed_zero_at_open() {
2301 let sa = sa(MarketSession::UsEquity);
2302 assert_eq!(sa.minutes_elapsed(MON_OPEN_UTC_MS), 0.0);
2303 }
2304
2305 #[test]
2306 fn test_minutes_elapsed_correct_at_30_min() {
2307 let sa = sa(MarketSession::UsEquity);
2308 let elapsed = sa.minutes_elapsed(MON_OPEN_UTC_MS + 30 * 60_000);
2309 assert!((elapsed - 30.0).abs() < 1e-9);
2310 }
2311
2312 #[test]
2313 fn test_minutes_elapsed_zero_when_closed() {
2314 let sa = sa(MarketSession::UsEquity);
2315 assert_eq!(sa.minutes_elapsed(SAT_UTC_MS), 0.0);
2316 }
2317
2318 #[test]
2319 fn test_is_power_hour_true_in_last_hour() {
2320 let sa = sa(MarketSession::UsEquity);
2321 assert!(sa.is_power_hour(MON_OPEN_UTC_MS + 19_800_000 + 60_000));
2323 }
2324
2325 #[test]
2326 fn test_is_power_hour_false_at_open() {
2327 let sa = sa(MarketSession::UsEquity);
2328 assert!(!sa.is_power_hour(MON_OPEN_UTC_MS));
2329 }
2330
2331 #[test]
2332 fn test_is_power_hour_false_when_closed() {
2333 let sa = sa(MarketSession::UsEquity);
2334 assert!(!sa.is_power_hour(SAT_UTC_MS));
2335 }
2336
2337 #[test]
2340 fn test_fraction_remaining_one_at_open() {
2341 let sa = sa(MarketSession::UsEquity);
2342 let f = sa.fraction_remaining(MON_OPEN_UTC_MS).unwrap();
2344 assert!((f - 1.0).abs() < 1e-6);
2345 }
2346
2347 #[test]
2348 fn test_fraction_remaining_zero_at_close() {
2349 let sa = sa(MarketSession::UsEquity);
2350 let near_close_ms = MON_OPEN_UTC_MS + 6 * 3_600_000 + 30 * 60_000 - 1;
2352 let f = sa.fraction_remaining(near_close_ms).unwrap();
2353 assert!(f >= 0.0 && f < 0.0001);
2354 }
2355
2356 #[test]
2357 fn test_fraction_remaining_none_when_closed() {
2358 let sa = sa(MarketSession::UsEquity);
2359 assert!(sa.fraction_remaining(SAT_UTC_MS).is_none());
2360 }
2361
2362 #[test]
2363 fn test_fraction_remaining_plus_progress_equals_one() {
2364 let sa = sa(MarketSession::UsEquity);
2365 let t = MON_OPEN_UTC_MS + 2 * 3_600_000;
2366 let prog = sa.session_progress(t).unwrap();
2367 let rem = sa.fraction_remaining(t).unwrap();
2368 assert!((prog + rem - 1.0).abs() < 1e-9);
2369 }
2370
2371 #[test]
2374 fn test_is_lunch_hour_true_at_midday() {
2375 let sa = sa(MarketSession::UsEquity);
2376 let t = MON_OPEN_UTC_MS + 150 * 60_000;
2378 assert!(sa.is_lunch_hour(t));
2379 }
2380
2381 #[test]
2382 fn test_is_lunch_hour_false_at_open() {
2383 let sa = sa(MarketSession::UsEquity);
2384 assert!(!sa.is_lunch_hour(MON_OPEN_UTC_MS));
2385 }
2386
2387 #[test]
2388 fn test_is_lunch_hour_false_outside_session() {
2389 let sa = sa(MarketSession::UsEquity);
2390 assert!(!sa.is_lunch_hour(SAT_UTC_MS));
2391 }
2392
2393 #[test]
2394 fn test_is_lunch_hour_false_for_crypto() {
2395 let sa = sa(MarketSession::Crypto);
2396 let t = MON_OPEN_UTC_MS + 150 * 60_000;
2397 assert!(!sa.is_lunch_hour(t));
2398 }
2399
2400 #[test]
2403 fn test_is_triple_witching_true_third_friday_march() {
2404 let date = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
2406 assert!(SessionAwareness::is_triple_witching(date));
2407 }
2408
2409 #[test]
2410 fn test_is_triple_witching_true_third_friday_september() {
2411 let date = NaiveDate::from_ymd_opt(2024, 9, 20).unwrap();
2413 assert!(SessionAwareness::is_triple_witching(date));
2414 }
2415
2416 #[test]
2417 fn test_is_triple_witching_false_wrong_month() {
2418 let date = NaiveDate::from_ymd_opt(2024, 1, 19).unwrap();
2420 assert!(!SessionAwareness::is_triple_witching(date));
2421 }
2422
2423 #[test]
2424 fn test_is_triple_witching_false_first_friday_of_witching_month() {
2425 let date = NaiveDate::from_ymd_opt(2024, 3, 1).unwrap();
2427 assert!(!SessionAwareness::is_triple_witching(date));
2428 }
2429
2430 #[test]
2431 fn test_is_triple_witching_false_wrong_weekday() {
2432 let date = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap();
2434 assert!(!SessionAwareness::is_triple_witching(date));
2435 }
2436
2437 #[test]
2440 fn test_trading_days_elapsed_same_day_weekday() {
2441 let d = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(); assert_eq!(SessionAwareness::trading_days_elapsed(d, d), 1);
2443 }
2444
2445 #[test]
2446 fn test_trading_days_elapsed_full_week() {
2447 let from = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(); let to = NaiveDate::from_ymd_opt(2024, 1, 12).unwrap(); assert_eq!(SessionAwareness::trading_days_elapsed(from, to), 5);
2450 }
2451
2452 #[test]
2453 fn test_trading_days_elapsed_excludes_weekends() {
2454 let from = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(); let to = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap(); assert_eq!(SessionAwareness::trading_days_elapsed(from, to), 5);
2458 }
2459
2460 #[test]
2461 fn test_trading_days_elapsed_zero_when_reversed() {
2462 let from = NaiveDate::from_ymd_opt(2024, 1, 12).unwrap();
2463 let to = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
2464 assert_eq!(SessionAwareness::trading_days_elapsed(from, to), 0);
2465 }
2466
2467 #[test]
2470 fn test_is_earnings_season_true_in_january() {
2471 let d = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
2472 assert!(SessionAwareness::is_earnings_season(d));
2473 }
2474
2475 #[test]
2476 fn test_is_earnings_season_true_in_october() {
2477 let d = NaiveDate::from_ymd_opt(2024, 10, 10).unwrap();
2478 assert!(SessionAwareness::is_earnings_season(d));
2479 }
2480
2481 #[test]
2482 fn test_is_earnings_season_false_in_march() {
2483 let d = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
2484 assert!(!SessionAwareness::is_earnings_season(d));
2485 }
2486
2487 #[test]
2490 fn test_week_of_month_first_day_is_week_one() {
2491 let d = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
2492 assert_eq!(SessionAwareness::week_of_month(d), 1);
2493 }
2494
2495 #[test]
2496 fn test_week_of_month_8th_is_week_two() {
2497 let d = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap();
2498 assert_eq!(SessionAwareness::week_of_month(d), 2);
2499 }
2500
2501 #[test]
2502 fn test_week_of_month_15th_is_week_three() {
2503 let d = NaiveDate::from_ymd_opt(2024, 3, 15).unwrap();
2504 assert_eq!(SessionAwareness::week_of_month(d), 3);
2505 }
2506
2507 #[test]
2510 fn test_fomc_blackout_true_for_late_odd_month() {
2511 let d = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap(); assert!(SessionAwareness::is_fomc_blackout_window(d));
2513 }
2514
2515 #[test]
2516 fn test_fomc_blackout_false_for_early_odd_month() {
2517 let d = NaiveDate::from_ymd_opt(2024, 3, 5).unwrap(); assert!(!SessionAwareness::is_fomc_blackout_window(d));
2519 }
2520
2521 #[test]
2522 fn test_fomc_blackout_false_for_even_month() {
2523 let d = NaiveDate::from_ymd_opt(2024, 4, 25).unwrap(); assert!(!SessionAwareness::is_fomc_blackout_window(d));
2525 }
2526
2527 #[test]
2528 fn test_fomc_blackout_boundary_day_18() {
2529 let d = NaiveDate::from_ymd_opt(2024, 1, 18).unwrap();
2530 assert!(SessionAwareness::is_fomc_blackout_window(d));
2531 }
2532
2533 #[test]
2536 fn test_holiday_adjacent_christmas_eve() {
2537 let d = NaiveDate::from_ymd_opt(2024, 12, 24).unwrap();
2538 assert!(SessionAwareness::is_market_holiday_adjacent(d));
2539 }
2540
2541 #[test]
2542 fn test_holiday_adjacent_day_after_christmas() {
2543 let d = NaiveDate::from_ymd_opt(2024, 12, 26).unwrap();
2544 assert!(SessionAwareness::is_market_holiday_adjacent(d));
2545 }
2546
2547 #[test]
2548 fn test_holiday_adjacent_new_years_eve() {
2549 let d = NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
2550 assert!(SessionAwareness::is_market_holiday_adjacent(d));
2551 }
2552
2553 #[test]
2554 fn test_holiday_adjacent_july_3() {
2555 let d = NaiveDate::from_ymd_opt(2024, 7, 3).unwrap();
2556 assert!(SessionAwareness::is_market_holiday_adjacent(d));
2557 }
2558
2559 #[test]
2560 fn test_holiday_adjacent_false_for_normal_day() {
2561 let d = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
2562 assert!(!SessionAwareness::is_market_holiday_adjacent(d));
2563 }
2564
2565 #[test]
2568 fn test_seconds_until_open_zero_when_session_is_open() {
2569 let sa = SessionAwareness::new(MarketSession::UsEquity);
2571 let mon_16h_utc: u64 = 4 * 24 * 3_600_000 + 16 * 3_600_000;
2572 assert_eq!(sa.seconds_until_open(mon_16h_utc), 0.0);
2573 }
2574
2575 #[test]
2576 fn test_seconds_until_open_positive_when_before_open() {
2577 let sa = SessionAwareness::new(MarketSession::UsEquity);
2579 let sat_midnight: u64 = 5 * 24 * 3_600_000;
2580 assert!(sa.seconds_until_open(sat_midnight) > 0.0);
2581 }
2582
2583 #[test]
2586 fn test_closing_bell_minute_true_near_session_end() {
2587 let sa = SessionAwareness::new(MarketSession::UsEquity);
2590 let mon_20_59_utc: u64 = 4 * 24 * 3_600_000 + 20 * 3_600_000 + 59 * 60_000 + 30_000;
2592 assert!(sa.is_closing_bell_minute(mon_20_59_utc));
2593 }
2594
2595 #[test]
2596 fn test_closing_bell_minute_false_early_in_session() {
2597 let sa = SessionAwareness::new(MarketSession::UsEquity);
2598 let mon_16h_utc: u64 = 4 * 24 * 3_600_000 + 16 * 3_600_000;
2600 assert!(!sa.is_closing_bell_minute(mon_16h_utc));
2601 }
2602
2603 #[test]
2606 fn test_day_of_week_name_monday() {
2607 let d = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); assert_eq!(SessionAwareness::day_of_week_name(d), "Monday");
2609 }
2610
2611 #[test]
2612 fn test_day_of_week_name_friday() {
2613 let d = NaiveDate::from_ymd_opt(2024, 1, 5).unwrap(); assert_eq!(SessionAwareness::day_of_week_name(d), "Friday");
2615 }
2616
2617 #[test]
2618 fn test_day_of_week_name_sunday() {
2619 let d = NaiveDate::from_ymd_opt(2024, 1, 7).unwrap(); assert_eq!(SessionAwareness::day_of_week_name(d), "Sunday");
2621 }
2622
2623 #[test]
2626 fn test_is_expiry_week_true_for_late_month() {
2627 let d = NaiveDate::from_ymd_opt(2024, 1, 25).unwrap();
2628 assert!(SessionAwareness::is_expiry_week(d));
2629 }
2630
2631 #[test]
2632 fn test_is_expiry_week_true_at_boundary_day_22() {
2633 let d = NaiveDate::from_ymd_opt(2024, 1, 22).unwrap();
2634 assert!(SessionAwareness::is_expiry_week(d));
2635 }
2636
2637 #[test]
2638 fn test_is_expiry_week_false_for_early_month() {
2639 let d = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
2640 assert!(!SessionAwareness::is_expiry_week(d));
2641 }
2642
2643 #[test]
2646 fn test_session_name_us_equity() {
2647 let sa = SessionAwareness::new(MarketSession::UsEquity);
2648 assert_eq!(sa.session_name(), "US Equity");
2649 }
2650
2651 #[test]
2652 fn test_session_name_crypto() {
2653 let sa = SessionAwareness::new(MarketSession::Crypto);
2654 assert_eq!(sa.session_name(), "Crypto");
2655 }
2656
2657 #[test]
2658 fn test_session_name_forex() {
2659 let sa = SessionAwareness::new(MarketSession::Forex);
2660 assert_eq!(sa.session_name(), "Forex");
2661 }
2662}