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
82pub struct SessionAwareness {
84 session: MarketSession,
85}
86
87impl SessionAwareness {
88 pub fn new(session: MarketSession) -> Self {
90 Self { session }
91 }
92
93 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 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 pub fn session(&self) -> MarketSession {
115 self.session
116 }
117
118 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn is_active(&self, utc_ms: u64) -> bool {
302 matches!(self.status(utc_ms), Ok(TradingStatus::Open) | Ok(TradingStatus::Extended))
303 }
304
305 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 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 pub fn is_liquid(&self, utc_ms: u64) -> bool {
347 self.is_open(utc_ms)
348 }
349
350 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; }
366 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 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 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 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 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 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 pub fn is_pre_open(&self, utc_ms: u64) -> bool {
450 self.is_pre_market(utc_ms)
451 }
452
453 pub fn day_fraction_remaining(&self, utc_ms: u64) -> f64 {
459 1.0 - self.fraction_of_day_elapsed(utc_ms)
460 }
461
462 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 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 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 pub fn open_duration_ms(&self) -> u64 {
498 self.session.session_duration_ms()
499 }
500
501 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 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 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 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 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 pub fn open_ms(&self, utc_ms: u64) -> u64 {
555 self.time_in_session_ms(utc_ms).unwrap_or(0)
556 }
557
558 pub fn progress_pct(&self, utc_ms: u64) -> f64 {
562 self.session_progress(utc_ms).unwrap_or(0.0) * 100.0
563 }
564
565 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 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 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 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 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 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 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 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 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 pub fn week_of_month(date: NaiveDate) -> u32 {
665 (date.day() - 1) / 7 + 1
666 }
667
668 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 pub fn is_expiry_week(date: NaiveDate) -> bool {
690 date.day() >= 22
691 }
692
693 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 pub fn is_earnings_season(date: NaiveDate) -> bool {
707 matches!(date.month(), 1 | 4 | 7 | 10)
708 }
709
710 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 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 let day = date.day();
737 day >= 15 && day <= 21
738 }
739
740 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 pub fn fraction_remaining(&self, utc_ms: u64) -> Option<f64> {
765 self.session_progress(utc_ms).map(|p| 1.0 - p)
766 }
767
768 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 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 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; elapsed + 60_000 >= session_length_ms
799 }
800 None => false,
801 }
802 }
803
804 pub fn is_market_holiday_adjacent(date: NaiveDate) -> bool {
811 let month = date.month();
812 let day = date.day();
813 if month == 12 && (day == 24 || day == 26) {
815 return true;
816 }
817 if (month == 12 && day == 31) || (month == 1 && day == 2) {
819 return true;
820 }
821 if month == 7 && (day == 3 || day == 5) {
823 return true;
824 }
825 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 pub fn is_fomc_blackout_window(date: NaiveDate) -> bool {
842 let day = date.day();
843 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 let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; 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 let days_to_friday: u64 = match day_of_week {
860 0 => 5, 1 => 4, 2 => 3, 3 => 2, 4 => 1, 5 => 0, _ => 6, };
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 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 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 let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; 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, 5 => 2, 6 => 1, _ => 0, };
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; let days_ahead: i64 = match dow {
926 Weekday::Mon | Weekday::Tue | Weekday::Wed | Weekday::Thu => {
927 if et_time_secs < open_s {
928 0 } else {
930 1 }
932 }
933 Weekday::Fri => {
934 if et_time_secs < open_s {
935 0 } else {
937 3 }
939 }
940 Weekday::Sat => 2, Weekday::Sun => 1, };
943
944 let et_date = et_dt.date_naive();
945 let mut target_date = et_date + Duration::days(days_ahead);
946
947 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 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 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 let dow = et_dt.weekday();
982 if dow == Weekday::Sat || dow == Weekday::Sun {
983 return TradingStatus::Closed;
984 }
985
986 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; 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 {
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 let day_of_week = (utc_ms / (24 * 3600 * 1000) + 4) % 7; let day_ms = utc_ms % (24 * 3600 * 1000);
1012 let hour_22_ms = 22 * 3600 * 1000;
1013
1014 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 if day_of_week == 5 && day_ms >= hour_22_ms {
1023 return TradingStatus::Closed;
1024 }
1025 TradingStatus::Open
1026 }
1027}
1028
1029fn 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 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 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
1053fn 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
1065fn 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
1074pub fn is_us_market_holiday(date: NaiveDate) -> bool {
1083 let year = date.year();
1084
1085 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 if date == observe(make_date(year, 1, 1)) {
1100 return true;
1101 }
1102 if date == nth_weekday_of_month(year, 1, Weekday::Mon, 3) {
1104 return true;
1105 }
1106 if date == nth_weekday_of_month(year, 2, Weekday::Mon, 3) {
1108 return true;
1109 }
1110 if date == good_friday(year) {
1112 return true;
1113 }
1114 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 if year >= 2022 && date == observe(make_date(year, 6, 19)) {
1123 return true;
1124 }
1125 if date == observe(make_date(year, 7, 4)) {
1127 return true;
1128 }
1129 if date == nth_weekday_of_month(year, 9, Weekday::Mon, 1) {
1131 return true;
1132 }
1133 if date == nth_weekday_of_month(year, 11, Weekday::Thu, 4) {
1135 return true;
1136 }
1137 if date == observe(make_date(year, 12, 25)) {
1139 return true;
1140 }
1141 false
1142}
1143
1144fn good_friday(year: i32) -> NaiveDate {
1146 easter_sunday(year) - Duration::days(2)
1147}
1148
1149fn 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
1169pub 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
1199pub 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 const MON_OPEN_UTC_MS: u64 = 1704724200000;
1212 const MON_CLOSE_UTC_MS: u64 = 1704747600000;
1214 const SAT_UTC_MS: u64 = 1705147200000;
1216 const SUN_BEFORE_UTC_MS: u64 = 1704621600000;
1218
1219 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 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 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 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 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 let just_before_dst_ms = 1710053940000_u64; let just_after_dst_ms = 1710054060000_u64; 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 let just_before_end_ms = 1730613540000_u64; let just_after_end_ms = 1730613660000_u64; 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 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 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 #[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 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 let sa = sa(MarketSession::UsEquity);
1385 let next = sa.next_open_ms(SAT_UTC_MS);
1386 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 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 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 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 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 #[test]
1435 fn test_holiday_new_years_day_2024() {
1436 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 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 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 assert_eq!(close, 1704762000000);
1631 }
1632
1633 #[test]
1634 fn test_next_close_equity_open_edt_returns_midnight_utc() {
1635 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 assert_eq!(close, 1720483200000);
1643 }
1644
1645 #[test]
1646 fn test_next_close_equity_extended_returns_20_00_et() {
1647 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 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 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 #[test]
1677 fn test_session_duration_us_equity_is_6_5_hours() {
1678 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 #[test]
1698 fn test_is_extended_crypto_is_never_extended() {
1699 let sa = sa(MarketSession::Crypto);
1700 assert!(!sa.is_extended(MON_OPEN_UTC_MS));
1702 }
1703
1704 #[test]
1705 fn test_is_extended_equity_during_extended_hours() {
1706 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 assert!(!sa.is_extended(MON_OPEN_UTC_MS));
1720 }
1721
1722 #[test]
1725 fn test_session_progress_none_when_closed() {
1726 let sa = sa(MarketSession::UsEquity);
1727 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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 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 #[test]
1899 fn test_is_weekend_saturday_is_weekend() {
1900 assert!(SessionAwareness::is_weekend(SAT_UTC_MS));
1902 }
1903
1904 #[test]
1905 fn test_is_weekend_sunday_is_weekend() {
1906 assert!(SessionAwareness::is_weekend(SUN_BEFORE_UTC_MS));
1908 }
1909
1910 #[test]
1911 fn test_is_weekend_monday_is_not_weekend() {
1912 assert!(!SessionAwareness::is_weekend(MON_OPEN_UTC_MS));
1914 }
1915
1916 #[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 #[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 assert!(!sa.is_regular_session(SUN_BEFORE_UTC_MS));
1956 }
1957
1958 #[test]
1961 fn test_fraction_of_day_elapsed_midnight_is_zero() {
1962 let sa = sa(MarketSession::Crypto);
1963 let midnight_ms: u64 = 24 * 60 * 60 * 1000; 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 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 #[test]
1989 fn test_remaining_until_close_ms_some_when_open() {
1990 let sa = sa(MarketSession::UsEquity);
1991 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 #[test]
2014 fn test_is_last_trading_hour_true_within_last_hour() {
2015 let sa = sa(MarketSession::UsEquity);
2016 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 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 #[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 assert!((sa.day_fraction_remaining(0) - 1.0).abs() < 1e-12);
2067 }
2068
2069 #[test]
2072 fn test_is_near_close_true_within_margin() {
2073 let sa = sa(MarketSession::UsEquity);
2074 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 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 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 #[test]
2109 fn test_is_overnight_true_when_closed_on_weekday() {
2110 let equity_sa = sa(MarketSession::UsEquity);
2111 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 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 #[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 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 #[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 #[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 #[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 #[test]
2223 fn test_is_extended_hours_true_in_pre_market() {
2224 let sa = sa(MarketSession::UsEquity);
2225 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 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 #[test]
2245 fn test_is_opening_range_true_at_open() {
2246 let sa = sa(MarketSession::UsEquity);
2247 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 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 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 #[test]
2274 fn test_is_mid_session_true_at_halfway_point() {
2275 let sa = sa(MarketSession::UsEquity);
2276 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 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 #[test]
2296 fn test_is_first_quarter_true_at_open() {
2297 let sa = sa(MarketSession::UsEquity);
2298 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 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 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 #[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 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 #[test]
2377 fn test_fraction_remaining_one_at_open() {
2378 let sa = sa(MarketSession::UsEquity);
2379 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 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 #[test]
2411 fn test_is_lunch_hour_true_at_midday() {
2412 let sa = sa(MarketSession::UsEquity);
2413 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 #[test]
2440 fn test_is_triple_witching_true_third_friday_march() {
2441 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 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 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 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 let date = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap();
2471 assert!(!SessionAwareness::is_triple_witching(date));
2472 }
2473
2474 #[test]
2477 fn test_trading_days_elapsed_same_day_weekday() {
2478 let d = NaiveDate::from_ymd_opt(2024, 1, 8).unwrap(); 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(); let to = NaiveDate::from_ymd_opt(2024, 1, 12).unwrap(); 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(); let to = NaiveDate::from_ymd_opt(2024, 1, 14).unwrap(); 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 #[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 #[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 #[test]
2547 fn test_fomc_blackout_true_for_late_odd_month() {
2548 let d = NaiveDate::from_ymd_opt(2024, 3, 20).unwrap(); 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(); 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(); 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 #[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 #[test]
2605 fn test_seconds_until_open_zero_when_session_is_open() {
2606 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 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 #[test]
2623 fn test_closing_bell_minute_true_near_session_end() {
2624 let sa = SessionAwareness::new(MarketSession::UsEquity);
2627 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 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 #[test]
2643 fn test_day_of_week_name_monday() {
2644 let d = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); 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(); 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(); assert_eq!(SessionAwareness::day_of_week_name(d), "Sunday");
2658 }
2659
2660 #[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 #[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}