Skip to main content

finance_dates/
trading_hours.rs

1//! Trading hours expressed in an IANA timezone with multi-session support.
2//!
3//! A trading day can have one or more `Session`s. Each session has an open
4//! time and a close time, both expressed as local clock times, plus optional
5//! day offsets that allow sessions to span midnight (e.g. CME Globex equity
6//! futures open 17:00 the previous calendar day and close 16:00 the same
7//! "trading day", and ICE energy futures open 18:00 the previous day and
8//! close 17:00 the same trading day).
9//!
10//! The "trading day" is the close-side calendar day. All session times are
11//! anchored to that day; offsets shift the open or close.
12
13use chrono::{DateTime, Duration, NaiveDate, NaiveTime, TimeZone, Utc};
14use chrono_tz::Tz;
15
16/// One contiguous trading session anchored to a trading day.
17///
18/// `open_day_offset` and `close_day_offset` are calendar-day offsets relative
19/// to the trading day (the close-side day). Typical values:
20/// - Regular cash equities: open=0, close=0
21/// - CME Globex equity futures: open=-1, close=0
22/// - 24x5 FX: open=-1, close=0
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct Session {
25    pub open: NaiveTime,
26    pub open_day_offset: i32,
27    pub close: NaiveTime,
28    pub close_day_offset: i32,
29}
30
31impl Session {
32    pub const fn regular(open: NaiveTime, close: NaiveTime) -> Self {
33        Self {
34            open,
35            open_day_offset: 0,
36            close,
37            close_day_offset: 0,
38        }
39    }
40
41    /// A session whose open is on the previous calendar day, close on the
42    /// trading day itself (the common futures pattern).
43    pub const fn overnight(open: NaiveTime, close: NaiveTime) -> Self {
44        Self {
45            open,
46            open_day_offset: -1,
47            close,
48            close_day_offset: 0,
49        }
50    }
51
52    /// Convert this session into a UTC `(open, close)` pair given a trading
53    /// day in the calendar's local timezone.
54    pub fn instants(
55        &self,
56        tz: Tz,
57        trading_day: NaiveDate,
58    ) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
59        let open_local = trading_day + Duration::days(self.open_day_offset as i64);
60        let close_local = trading_day + Duration::days(self.close_day_offset as i64);
61        let open = tz
62            .from_local_datetime(&open_local.and_time(self.open))
63            .single()?;
64        let close = tz
65            .from_local_datetime(&close_local.and_time(self.close))
66            .single()?;
67        Some((open.with_timezone(&Utc), close.with_timezone(&Utc)))
68    }
69}
70
71/// One named non-regular trading window, such as pre-open or after-close.
72#[derive(Clone, Copy, Debug, PartialEq, Eq)]
73pub struct ExtendedSession {
74    pub name: &'static str,
75    pub session: Session,
76}
77
78impl ExtendedSession {
79    pub const fn new(name: &'static str, session: Session) -> Self {
80        Self { name, session }
81    }
82}
83
84/// One or more sessions per trading day, all in the same local timezone.
85#[derive(Clone, Debug)]
86pub struct TradingHours {
87    pub sessions: Vec<Session>,
88    pub extended_sessions: Vec<ExtendedSession>,
89    pub timezone: Tz,
90}
91
92impl TradingHours {
93    /// Single regular session (open and close on the trading day).
94    pub fn new(open: NaiveTime, close: NaiveTime, timezone: Tz) -> Self {
95        Self {
96            sessions: vec![Session::regular(open, close)],
97            extended_sessions: Vec::new(),
98            timezone,
99        }
100    }
101
102    pub fn from_sessions(sessions: Vec<Session>, timezone: Tz) -> Self {
103        Self {
104            sessions,
105            extended_sessions: Vec::new(),
106            timezone,
107        }
108    }
109
110    pub fn with_extended_sessions(mut self, extended_sessions: Vec<ExtendedSession>) -> Self {
111        self.extended_sessions = extended_sessions;
112        self
113    }
114
115    /// Convenience: 24-hour-a-day, 5-day-a-week (open prev 17:00, close 17:00 NY).
116    pub fn forex_24x5() -> Self {
117        Self::from_sessions(
118            vec![Session::overnight(
119                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
120                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
121            )],
122            chrono_tz::America::New_York,
123        )
124    }
125
126    /// 24x7 always-open marker (UTC, single full-day session).
127    pub fn crypto_24x7() -> Self {
128        Self::from_sessions(
129            vec![Session {
130                open: NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
131                open_day_offset: 0,
132                close: NaiveTime::from_hms_opt(23, 59, 59).unwrap(),
133                close_day_offset: 0,
134            }],
135            chrono_tz::UTC,
136        )
137    }
138
139    /// True iff `instant` falls within at least one session. The caller must
140    /// ensure the relevant trading day is itself a business day; this method
141    /// scans a 3-day window so cross-midnight sessions still resolve.
142    pub fn contains_local_time(&self, instant: DateTime<Utc>) -> bool {
143        let local_today = instant.with_timezone(&self.timezone).date_naive();
144        for delta in [-1i64, 0, 1] {
145            let day = local_today + Duration::days(delta);
146            for s in &self.sessions {
147                if let Some((o, c)) = s.instants(self.timezone, day) {
148                    if instant >= o && instant < c {
149                        return true;
150                    }
151                }
152            }
153        }
154        false
155    }
156
157    /// First session open instant for the given trading day.
158    pub fn open_at(&self, year: i32, month: u32, day: u32) -> Option<DateTime<Utc>> {
159        let nd = NaiveDate::from_ymd_opt(year, month, day)?;
160        self.sessions
161            .first()
162            .and_then(|s| s.instants(self.timezone, nd).map(|(o, _)| o))
163    }
164
165    /// Last session close instant for the given trading day.
166    pub fn close_at(&self, year: i32, month: u32, day: u32) -> Option<DateTime<Utc>> {
167        let nd = NaiveDate::from_ymd_opt(year, month, day)?;
168        self.sessions
169            .last()
170            .and_then(|s| s.instants(self.timezone, nd).map(|(_, c)| c))
171    }
172}
173
174/// Convenience: parse "HH:MM" into a NaiveTime.
175pub fn parse_hhmm(s: &str) -> Option<NaiveTime> {
176    NaiveTime::parse_from_str(s, "%H:%M").ok()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use chrono::TimeZone;
183    use chrono_tz::America::{Chicago, New_York};
184
185    #[test]
186    fn nyse_contains_local() {
187        let th = TradingHours::new(
188            NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
189            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
190            New_York,
191        );
192        let inst = New_York
193            .with_ymd_and_hms(2024, 1, 8, 9, 30, 0)
194            .unwrap()
195            .with_timezone(&Utc);
196        assert!(th.contains_local_time(inst));
197        let before = inst - Duration::minutes(1);
198        assert!(!th.contains_local_time(before));
199    }
200
201    #[test]
202    fn cme_equity_futures_overnight_open() {
203        let th = TradingHours::from_sessions(
204            vec![Session::overnight(
205                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
206                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
207            )],
208            Chicago,
209        );
210        // Sun Jan 7 2024 18:00 CT should be in session for Mon Jan 8 trading day.
211        let inst = Chicago
212            .with_ymd_and_hms(2024, 1, 7, 18, 0, 0)
213            .unwrap()
214            .with_timezone(&Utc);
215        assert!(th.contains_local_time(inst));
216        // Mon Jan 8 2024 16:30 CT — outside the 16:00 close.
217        let inst2 = Chicago
218            .with_ymd_and_hms(2024, 1, 8, 16, 30, 0)
219            .unwrap()
220            .with_timezone(&Utc);
221        assert!(!th.contains_local_time(inst2));
222    }
223
224    #[test]
225    fn forex_continuous_24x5() {
226        let th = TradingHours::forex_24x5();
227        // Tuesday 03:00 NY — should be open (between Mon 17:00 → Tue 17:00).
228        let inst = New_York
229            .with_ymd_and_hms(2024, 1, 9, 3, 0, 0)
230            .unwrap()
231            .with_timezone(&Utc);
232        assert!(th.contains_local_time(inst));
233        // Note: this method does not enforce the weekmask — the Calendar layer
234        // does. See `Calendar::is_open` for the full Mon-Fri filter.
235    }
236}