Skip to main content

kestrel_chartkit/
calendar.rs

1//! IANA-timezone, DST-aware exchange calendars.
2//!
3//! [`crate::session::SessionConfig`] models a recurring session at a fixed UTC offset — correct
4//! for markets that never observe daylight saving time, but wrong twice a year for any exchange
5//! whose local trading hours are defined in a DST-observing timezone (its UTC offset shifts).
6//! [`ExchangeCalendar`] complements it: an IANA timezone (via `chrono-tz`, which embeds the
7//! timezone database rather than depending on the host OS's), a holiday set, per-date early
8//! closes, and one or more session segments per trading day, all evaluated in the exchange's true
9//! local wall-clock time.
10//!
11//! Requires the `calendar` feature (off by default): the core crate has no timezone-database
12//! dependency unless a consumer opts in.
13
14use std::collections::{HashMap, HashSet};
15
16use chrono::{DateTime, NaiveDate, NaiveTime, Offset, TimeZone, Utc};
17use chrono_tz::Tz;
18
19/// A single trading session segment within a trading day, in the exchange's local time.
20/// `end` is exclusive, matching [`ExchangeCalendar::is_in_session`]'s half-open interval.
21#[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/// A provider-neutral exchange calendar: an IANA timezone (DST-aware), a fixed holiday set,
34/// per-date early-close overrides, and one or more regular session segments per trading day.
35#[derive(Debug, Clone)]
36pub struct ExchangeCalendar {
37    pub timezone: Tz,
38    pub sessions: Vec<SessionSegment>,
39    holidays: HashSet<NaiveDate>,
40    /// Date -> local close time overriding the first session segment's regular end for that date.
41    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    /// Converts a Unix timestamp (seconds) to this exchange's local wall-clock date/time,
65    /// correctly applying DST transitions via the IANA database.
66    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    /// The exchange's true UTC offset at `unix_ts`, in seconds — varies across DST transitions,
75    /// unlike [`crate::session::SessionConfig::utc_offset_seconds`]'s fixed value.
76    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    /// Whether `unix_ts` falls within a regular (or early-closed) trading session segment, in
89    /// exchange local time. Always `false` on a configured holiday. On a configured early-close
90    /// date, only the first session segment applies, truncated at the override close time.
91    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        // 2024-01-15: EST (UTC-5). 2024-07-15: EDT (UTC-4). Same local wall-clock time, 9:30.
140        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        // Both are still 9:30 local -> both in session, despite the differing UTC offset.
146        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}