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 or more sessions per trading day, all in the same local timezone.
72#[derive(Clone, Debug)]
73pub struct TradingHours {
74    pub sessions: Vec<Session>,
75    pub timezone: Tz,
76}
77
78impl TradingHours {
79    /// Single regular session (open and close on the trading day).
80    pub fn new(open: NaiveTime, close: NaiveTime, timezone: Tz) -> Self {
81        Self {
82            sessions: vec![Session::regular(open, close)],
83            timezone,
84        }
85    }
86
87    pub fn from_sessions(sessions: Vec<Session>, timezone: Tz) -> Self {
88        Self { sessions, timezone }
89    }
90
91    /// Convenience: 24-hour-a-day, 5-day-a-week (open prev 17:00, close 17:00 NY).
92    pub fn forex_24x5() -> Self {
93        Self::from_sessions(
94            vec![Session::overnight(
95                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
96                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
97            )],
98            chrono_tz::America::New_York,
99        )
100    }
101
102    /// 24x7 always-open marker (UTC, single full-day session).
103    pub fn crypto_24x7() -> Self {
104        Self::from_sessions(
105            vec![Session {
106                open: NaiveTime::from_hms_opt(0, 0, 0).unwrap(),
107                open_day_offset: 0,
108                close: NaiveTime::from_hms_opt(23, 59, 59).unwrap(),
109                close_day_offset: 0,
110            }],
111            chrono_tz::UTC,
112        )
113    }
114
115    /// True iff `instant` falls within at least one session. The caller must
116    /// ensure the relevant trading day is itself a business day; this method
117    /// scans a 3-day window so cross-midnight sessions still resolve.
118    pub fn contains_local_time(&self, instant: DateTime<Utc>) -> bool {
119        let local_today = instant.with_timezone(&self.timezone).date_naive();
120        for delta in [-1i64, 0, 1] {
121            let day = local_today + Duration::days(delta);
122            for s in &self.sessions {
123                if let Some((o, c)) = s.instants(self.timezone, day) {
124                    if instant >= o && instant < c {
125                        return true;
126                    }
127                }
128            }
129        }
130        false
131    }
132
133    /// First session open instant for the given trading day.
134    pub fn open_at(&self, year: i32, month: u32, day: u32) -> Option<DateTime<Utc>> {
135        let nd = NaiveDate::from_ymd_opt(year, month, day)?;
136        self.sessions
137            .first()
138            .and_then(|s| s.instants(self.timezone, nd).map(|(o, _)| o))
139    }
140
141    /// Last session close instant for the given trading day.
142    pub fn close_at(&self, year: i32, month: u32, day: u32) -> Option<DateTime<Utc>> {
143        let nd = NaiveDate::from_ymd_opt(year, month, day)?;
144        self.sessions
145            .last()
146            .and_then(|s| s.instants(self.timezone, nd).map(|(_, c)| c))
147    }
148}
149
150/// Convenience: parse "HH:MM" into a NaiveTime.
151pub fn parse_hhmm(s: &str) -> Option<NaiveTime> {
152    NaiveTime::parse_from_str(s, "%H:%M").ok()
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use chrono::TimeZone;
159    use chrono_tz::America::{Chicago, New_York};
160
161    #[test]
162    fn nyse_contains_local() {
163        let th = TradingHours::new(
164            NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
165            NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
166            New_York,
167        );
168        let inst = New_York
169            .with_ymd_and_hms(2024, 1, 8, 9, 30, 0)
170            .unwrap()
171            .with_timezone(&Utc);
172        assert!(th.contains_local_time(inst));
173        let before = inst - Duration::minutes(1);
174        assert!(!th.contains_local_time(before));
175    }
176
177    #[test]
178    fn cme_equity_futures_overnight_open() {
179        let th = TradingHours::from_sessions(
180            vec![Session::overnight(
181                NaiveTime::from_hms_opt(17, 0, 0).unwrap(),
182                NaiveTime::from_hms_opt(16, 0, 0).unwrap(),
183            )],
184            Chicago,
185        );
186        // Sun Jan 7 2024 18:00 CT should be in session for Mon Jan 8 trading day.
187        let inst = Chicago
188            .with_ymd_and_hms(2024, 1, 7, 18, 0, 0)
189            .unwrap()
190            .with_timezone(&Utc);
191        assert!(th.contains_local_time(inst));
192        // Mon Jan 8 2024 16:30 CT — outside the 16:00 close.
193        let inst2 = Chicago
194            .with_ymd_and_hms(2024, 1, 8, 16, 30, 0)
195            .unwrap()
196            .with_timezone(&Utc);
197        assert!(!th.contains_local_time(inst2));
198    }
199
200    #[test]
201    fn forex_continuous_24x5() {
202        let th = TradingHours::forex_24x5();
203        // Tuesday 03:00 NY — should be open (between Mon 17:00 → Tue 17:00).
204        let inst = New_York
205            .with_ymd_and_hms(2024, 1, 9, 3, 0, 0)
206            .unwrap()
207            .with_timezone(&Utc);
208        assert!(th.contains_local_time(inst));
209        // Note: this method does not enforce the weekmask — the Calendar layer
210        // does. See `Calendar::is_open` for the full Mon-Fri filter.
211    }
212}