1use std::collections::{HashMap, HashSet};
15
16use chrono::{DateTime, NaiveDate, NaiveTime, Offset, TimeZone, Utc};
17use chrono_tz::Tz;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct SessionSegment {
23 pub start: NaiveTime,
24 pub end: NaiveTime,
25}
26
27impl SessionSegment {
28 pub fn new(start: NaiveTime, end: NaiveTime) -> Self {
29 Self { start, end }
30 }
31}
32
33#[derive(Debug, Clone)]
36pub struct ExchangeCalendar {
37 pub timezone: Tz,
38 pub sessions: Vec<SessionSegment>,
39 holidays: HashSet<NaiveDate>,
40 early_closes: HashMap<NaiveDate, NaiveTime>,
42}
43
44impl ExchangeCalendar {
45 pub fn new(timezone: Tz, sessions: Vec<SessionSegment>) -> Self {
46 Self {
47 timezone,
48 sessions,
49 holidays: HashSet::new(),
50 early_closes: HashMap::new(),
51 }
52 }
53
54 pub fn with_holidays(mut self, holidays: impl IntoIterator<Item = NaiveDate>) -> Self {
55 self.holidays.extend(holidays);
56 self
57 }
58
59 pub fn with_early_close(mut self, date: NaiveDate, close: NaiveTime) -> Self {
60 self.early_closes.insert(date, close);
61 self
62 }
63
64 pub fn local_datetime(&self, unix_ts: i64) -> DateTime<Tz> {
67 let utc = Utc
68 .timestamp_opt(unix_ts, 0)
69 .single()
70 .expect("unix_ts must be a valid, unambiguous UTC instant");
71 utc.with_timezone(&self.timezone)
72 }
73
74 pub fn utc_offset_seconds(&self, unix_ts: i64) -> i32 {
77 self.local_datetime(unix_ts)
78 .offset()
79 .fix()
80 .local_minus_utc()
81 }
82
83 pub fn is_holiday(&self, unix_ts: i64) -> bool {
84 self.holidays
85 .contains(&self.local_datetime(unix_ts).date_naive())
86 }
87
88 pub fn is_in_session(&self, unix_ts: i64) -> bool {
92 if self.is_holiday(unix_ts) {
93 return false;
94 }
95
96 let local = self.local_datetime(unix_ts);
97 let date = local.date_naive();
98 let time = local.time();
99
100 if let Some(&early_close) = self.early_closes.get(&date) {
101 return self
102 .sessions
103 .first()
104 .is_some_and(|s| time >= s.start && time < early_close);
105 }
106
107 self.sessions
108 .iter()
109 .any(|s| time >= s.start && time < s.end)
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use chrono::NaiveDate;
117
118 fn new_york_regular_session() -> ExchangeCalendar {
119 ExchangeCalendar::new(
120 chrono_tz::America::New_York,
121 vec![SessionSegment::new(
122 NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
123 NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
124 )],
125 )
126 }
127
128 fn unix_ts(y: i32, m: u32, d: u32, h: u32, min: u32, tz: Tz) -> i64 {
129 tz.with_ymd_and_hms(y, m, d, h, min, 0)
130 .single()
131 .unwrap()
132 .with_timezone(&Utc)
133 .timestamp()
134 }
135
136 #[test]
137 fn test_dst_transition_shifts_utc_offset() {
138 let calendar = new_york_regular_session();
139 let winter = unix_ts(2024, 1, 15, 9, 30, chrono_tz::America::New_York);
141 let summer = unix_ts(2024, 7, 15, 9, 30, chrono_tz::America::New_York);
142
143 assert_eq!(calendar.utc_offset_seconds(winter), -5 * 3600);
144 assert_eq!(calendar.utc_offset_seconds(summer), -4 * 3600);
145 assert!(calendar.is_in_session(winter));
147 assert!(calendar.is_in_session(summer));
148 }
149
150 #[test]
151 fn test_is_in_session_respects_local_open_close() {
152 let calendar = new_york_regular_session();
153 let before_open = unix_ts(2024, 6, 10, 9, 0, chrono_tz::America::New_York);
154 let during = unix_ts(2024, 6, 10, 12, 0, chrono_tz::America::New_York);
155 let at_close = unix_ts(2024, 6, 10, 16, 0, chrono_tz::America::New_York);
156
157 assert!(!calendar.is_in_session(before_open));
158 assert!(calendar.is_in_session(during));
159 assert!(!calendar.is_in_session(at_close), "end is exclusive");
160 }
161
162 #[test]
163 fn test_holiday_overrides_regular_session() {
164 let holiday = NaiveDate::from_ymd_opt(2024, 7, 4).unwrap();
165 let calendar = new_york_regular_session().with_holidays([holiday]);
166
167 let during_holiday = unix_ts(2024, 7, 4, 12, 0, chrono_tz::America::New_York);
168 assert!(calendar.is_holiday(during_holiday));
169 assert!(!calendar.is_in_session(during_holiday));
170 }
171
172 #[test]
173 fn test_early_close_truncates_session() {
174 let date = NaiveDate::from_ymd_opt(2024, 11, 29).unwrap();
175 let early_close_time = NaiveTime::from_hms_opt(13, 0, 0).unwrap();
176 let calendar = new_york_regular_session().with_early_close(date, early_close_time);
177
178 let after_regular_open = unix_ts(2024, 11, 29, 12, 0, chrono_tz::America::New_York);
179 let after_early_close = unix_ts(2024, 11, 29, 14, 0, chrono_tz::America::New_York);
180
181 assert!(calendar.is_in_session(after_regular_open));
182 assert!(!calendar.is_in_session(after_early_close));
183 }
184}