Skip to main content

egui_charts/model/session/
exchange.rs

1//! Exchange session provider: trading hours, DST and holidays.
2
3use super::{SessionBreak, SessionBreakType, SessionProvider, local_instant, local_time};
4use crate::model::{Bar, OverrideWindow, Session, Timeframe, TimezoneId};
5use chrono::{DateTime, Datelike, Days, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc};
6
7/// A window that closes on one trading date.
8struct Hours {
9    open: NaiveTime,
10    close: NaiveTime,
11    extended: bool,
12}
13
14impl Hours {
15    fn new(open: NaiveTime, close: NaiveTime, extended: bool) -> Self {
16        Self {
17            open,
18            close,
19            extended,
20        }
21    }
22
23    /// Whether the window opens on the day before it closes.
24    fn overnight(&self) -> bool {
25        self.close <= self.open && self.open != NaiveTime::MIN
26    }
27}
28
29/// The trading state of an exchange session at one instant, as the legend's market status
30/// shows it.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32#[cfg(any(feature = "widget", all(test, feature = "timezones")))]
33pub(crate) enum MarketSession {
34    /// Inside regular trading hours.
35    Open,
36    /// Inside extended hours that regular hours of the same trading date follow.
37    PreMarket,
38    /// Inside extended hours that no regular hours of the same trading date follow.
39    PostMarket,
40    /// Outside trading hours, including gaps between windows, weekends and holidays.
41    Closed,
42}
43
44/// Daily session provider for an exchange: a day starts at each session open of its trading
45/// hours, in its time zone, so bars outside the hours stay with the session before them.
46/// Without trading hours a day starts at midnight in the time zone.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ExchangeSessionProvider {
49    time_zone: TimezoneId,
50    calendar_zone: Option<TimezoneId>,
51    session: Option<Session>,
52    timeframe: Timeframe,
53    local_zone: bool,
54}
55
56impl ExchangeSessionProvider {
57    /// Days of `session`, or calendar days without one, in `time_zone`.
58    pub fn new(time_zone: TimezoneId, session: Option<Session>) -> Self {
59        Self {
60            calendar_zone: Some(time_zone.clone()),
61            time_zone,
62            session,
63            timeframe: Timeframe::Min1,
64            local_zone: false,
65        }
66    }
67
68    /// Calendar sessions in the system's local zone when no exchange zone is known.
69    pub fn local(session: Option<Session>) -> Self {
70        Self {
71            local_zone: true,
72            calendar_zone: None,
73            ..Self::new(TimezoneId::utc(), session)
74        }
75    }
76
77    /// The calendar that aligned daily and longer bars; `None` uses the system's local zone.
78    pub(crate) fn with_calendar_zone(mut self, zone: Option<TimezoneId>) -> Self {
79        self.calendar_zone = zone;
80        self
81    }
82
83    fn wall_time(&self, time: DateTime<Utc>) -> NaiveDateTime {
84        if self.local_zone {
85            local_time(chrono::Local, time)
86        } else {
87            local_time(self.time_zone.clone(), time)
88        }
89    }
90
91    fn instant(&self, time: NaiveDateTime) -> Option<DateTime<Utc>> {
92        if self.local_zone {
93            local_instant(chrono::Local, time)
94        } else {
95            local_instant(self.time_zone.clone(), time)
96        }
97    }
98
99    fn close_instant(&self, time: NaiveDateTime) -> Option<DateTime<Utc>> {
100        let latest = if self.local_zone {
101            chrono::Local
102                .from_local_datetime(&time)
103                .latest()
104                .map(|time| time.to_utc())
105        } else {
106            self.time_zone
107                .from_local_datetime(&time)
108                .latest()
109                .map(|time| time.to_utc())
110        };
111        latest.or_else(|| self.instant(time))
112    }
113
114    /// Draw week boundaries on daily bars, month boundaries on multi-day and weekly bars,
115    /// and year boundaries on monthly bars shorter than a year. Yearly and longer bars have no dividers.
116    pub fn with_timeframe(mut self, timeframe: Timeframe) -> Self {
117        self.timeframe = timeframe;
118        self
119    }
120
121    /// The trading date containing `time`, using the earliest window of that date for intraday
122    /// bars. Daily and longer bars carry their trading date in UTC, or in their clock's calendar
123    /// or session zone when they open at its first instant of the date or at session midnight.
124    pub fn trading_date(&self, time: DateTime<Utc>) -> NaiveDate {
125        if self.timeframe.duration_ms() >= Timeframe::Day1.duration_ms() {
126            self.bar_date(time)
127        } else {
128            self.trading_date_in(&self.opens([time]), time)
129        }
130    }
131
132    /// The date of a daily or longer bar, recognizing skipped and repeated session midnights.
133    fn bar_date(&self, open: DateTime<Utc>) -> NaiveDate {
134        let (date, first) = match &self.calendar_zone {
135            Some(zone) => {
136                let date = local_time(zone.clone(), open).date();
137                (
138                    date,
139                    local_instant(zone.clone(), date.and_time(NaiveTime::MIN)),
140                )
141            }
142            None => {
143                let date = local_time(chrono::Local, open).date();
144                (
145                    date,
146                    local_instant(chrono::Local, date.and_time(NaiveTime::MIN)),
147                )
148            }
149        };
150        if first == Some(open) {
151            return date;
152        }
153        let wall = self.wall_time(open);
154        if wall.time() == NaiveTime::MIN
155            || self.instant(wall.date().and_time(NaiveTime::MIN)) == Some(open)
156        {
157            wall.date()
158        } else {
159            open.date_naive()
160        }
161    }
162
163    /// Whether `time` lies in extended hours and outside regular hours.
164    pub fn is_extended(&self, time: DateTime<Utc>) -> bool {
165        let closes = self.window_closes(time, true);
166        closes.iter().any(|(extended, ..)| *extended)
167            && !closes.iter().any(|(extended, ..)| !extended)
168    }
169
170    /// Whether the exchange is open, optionally including extended windows.
171    pub fn is_open(&self, time: DateTime<Utc>, extended_hours: bool) -> bool {
172        let Some(session) = &self.session else {
173            return true;
174        };
175        let date = self.wall_time(time).date();
176        if self.continuous_on(date) {
177            return !session.holidays.contains(&date);
178        }
179        !self.window_closes(time, extended_hours).is_empty()
180    }
181
182    /// The state of the session at `time`, or `None` without trading hours.
183    #[cfg(any(feature = "widget", all(test, feature = "timezones")))]
184    pub(crate) fn market_session(&self, time: DateTime<Utc>) -> Option<MarketSession> {
185        self.session.as_ref()?;
186        if self.is_open(time, false) {
187            return Some(MarketSession::Open);
188        }
189        let windows = self.window_closes(time, true);
190        let pre = windows.iter().any(|&(_, date, close)| {
191            self.hours_on(date).iter().any(|hours| {
192                !hours.extended
193                    && self
194                        .window_open(date, hours)
195                        .is_some_and(|open| open >= close)
196            })
197        });
198        Some(if pre {
199            MarketSession::PreMarket
200        } else if windows.is_empty() {
201            MarketSession::Closed
202        } else {
203            MarketSession::PostMarket
204        })
205    }
206
207    /// Closing instant of the active trading window. Outside trading hours, including holidays,
208    /// returns `None`. Continuous sessions end at the next local midnight.
209    /// Extended windows participate only when requested.
210    pub fn session_close(&self, time: DateTime<Utc>, extended: bool) -> Option<DateTime<Utc>> {
211        let session = self.session.as_ref()?;
212        let date = self.wall_time(time).date();
213        if self.continuous_on(date) {
214            return if session.holidays.contains(&date) {
215                None
216            } else {
217                self.instant(date.succ_opt()?.and_time(NaiveTime::MIN))
218            };
219        }
220        self.window_closes(time, extended)
221            .into_iter()
222            .map(|(.., close)| close)
223            .max()
224    }
225
226    /// Close of the last trading window in a daily, weekly or monthly bar. Daily and longer
227    /// bars are dated as [`trading_date`](Self::trading_date) dates them; intraday bars use their
228    /// active exchange window.
229    pub fn bar_session_close(
230        &self,
231        open: DateTime<Utc>,
232        timeframe: Timeframe,
233        extended: bool,
234    ) -> Option<DateTime<Utc>> {
235        if timeframe.duration_ms() < 86_400_000 {
236            return self.session_close(open, extended);
237        }
238        let session = self.session.as_ref()?;
239        if session.windows.is_empty()
240            && session.overrides.is_empty()
241            && (!extended || session.extended_windows.is_empty())
242        {
243            return None;
244        }
245        let date = self.bar_date(open);
246        let end = match timeframe {
247            _ if timeframe.duration_ms() == Timeframe::Day1.duration_ms() => date.succ_opt()?,
248            Timeframe::Week1 => date.checked_add_days(chrono::Days::new(7))?,
249            Timeframe::Month1 => date
250                .with_day(1)?
251                .checked_add_months(chrono::Months::new(1))?,
252            Timeframe::Weeks(count) => {
253                date.checked_add_days(chrono::Days::new(u64::from(count.get()) * 7))?
254            }
255            Timeframe::Months(count) => date
256                .with_day(1)?
257                .checked_add_months(chrono::Months::new(count.get()))?,
258            _ => return None,
259        };
260        let start = date;
261        let mut date = end.pred_opt()?;
262        // Only the final trading date is needed, even for long calendar intervals.
263        while date >= start {
264            let close = self
265                .hours_on(date)
266                .iter()
267                .filter(|hours| extended || !hours.extended)
268                .filter_map(|hours| self.window_span(date, hours))
269                .map(|(_, close)| close)
270                .max();
271            if close.is_some() {
272                return close;
273            }
274            date = date.pred_opt()?;
275        }
276        None
277    }
278
279    /// The windows that close on `date`, tagged extended or regular: those of its override, else
280    /// none on a holiday, else the weekly windows of its weekday.
281    fn hours_on(&self, date: NaiveDate) -> Vec<Hours> {
282        let Some(session) = &self.session else {
283            return Vec::new();
284        };
285        if let Some(day) = session.overrides.iter().find(|day| day.date == date) {
286            let tag = |extended| move |w: &OverrideWindow| Hours::new(w.open, w.close, extended);
287            return day
288                .windows
289                .iter()
290                .map(tag(false))
291                .chain(day.extended_windows.iter().map(tag(true)))
292                .collect();
293        }
294        if session.holidays.contains(&date) {
295            return Vec::new();
296        }
297        let tag = |extended| {
298            move |w: &crate::model::SessionWindow| {
299                w.days
300                    .contains(&date.weekday())
301                    .then(|| Hours::new(w.open, w.close, extended))
302            }
303        };
304        session
305            .windows
306            .iter()
307            .filter_map(tag(false))
308            .chain(session.extended_windows.iter().filter_map(tag(true)))
309            .collect()
310    }
311
312    /// Whether `date` has no windows and is open all day unless it is a holiday: a session
313    /// without weekly windows and without an override for the date.
314    fn continuous_on(&self, date: NaiveDate) -> bool {
315        self.session.as_ref().is_some_and(|session| {
316            session.windows.is_empty()
317                && session.extended_windows.is_empty()
318                && !session.overrides.iter().any(|day| day.date == date)
319        })
320    }
321
322    /// Whether anything trades on `date`.
323    fn trades_on(&self, date: NaiveDate) -> bool {
324        if self.continuous_on(date) {
325            self.session
326                .as_ref()
327                .is_some_and(|session| !session.holidays.contains(&date))
328        } else {
329            !self.hours_on(date).is_empty()
330        }
331    }
332
333    /// The opening instant of a window that closes on `close_date`.
334    fn window_open(&self, close_date: NaiveDate, hours: &Hours) -> Option<DateTime<Utc>> {
335        let open_date = if hours.overnight() {
336            close_date.pred_opt()?
337        } else {
338            close_date
339        };
340        self.instant(open_date.and_time(hours.open))
341    }
342
343    /// The opening and closing instants of a window that closes on `close_date`.
344    fn window_span(
345        &self,
346        close_date: NaiveDate,
347        hours: &Hours,
348    ) -> Option<(DateTime<Utc>, DateTime<Utc>)> {
349        let end_date = if hours.open == hours.close && !hours.overnight() {
350            close_date.succ_opt()?
351        } else {
352            close_date
353        };
354        // The later close retains the full repeated hour in an overnight window.
355        let close = self.close_instant(end_date.and_time(hours.close))?;
356        Some((self.window_open(close_date, hours)?, close))
357    }
358
359    /// The closing instants of the windows containing `time`, each tagged extended or regular
360    /// and with the date it closes on.
361    /// Extended windows are left out unless `extended`.
362    fn window_closes(
363        &self,
364        time: DateTime<Utc>,
365        extended: bool,
366    ) -> Vec<(bool, NaiveDate, DateTime<Utc>)> {
367        let date = self.wall_time(time).date();
368        let mut closes = Vec::new();
369        for (close_date, next_day) in [(Some(date), false), (date.succ_opt(), true)] {
370            let Some(close_date) = close_date else {
371                continue;
372            };
373            for hours in self.hours_on(close_date) {
374                if (hours.extended && !extended) || (next_day && !hours.overnight()) {
375                    continue;
376                }
377                if let Some((open, close)) = self.window_span(close_date, &hours)
378                    && time >= open
379                    && time < close
380                {
381                    closes.push((hours.extended, close_date, close));
382                }
383            }
384        }
385        closes
386    }
387
388    fn period(&self, date: NaiveDate) -> (i32, u32) {
389        if self.timeframe.duration_ms() >= 12 * Timeframe::Month1.duration_ms() {
390            (0, 0)
391        } else if self.timeframe.duration_ms() < Timeframe::Day1.duration_ms() {
392            (date.year(), date.ordinal())
393        } else if self.timeframe.duration_ms() == Timeframe::Day1.duration_ms() {
394            (date.iso_week().year(), date.iso_week().week())
395        } else if matches!(self.timeframe, Timeframe::Month1 | Timeframe::Months(_)) {
396            (date.year(), 0)
397        } else {
398            (date.year(), date.month())
399        }
400    }
401
402    /// The instants trading days start around `times`, sorted, each with its trading date: the
403    /// session opens whose windows close on dates from at least 9 days before the UTC date
404    /// of a time to 2 days after it (the local date is within a day of the UTC one, a weekly schedule opens
405    /// at least once a week, and a window open since the evening closes the next day), or local
406    /// midnights without trading hours. The range extends back through closures to a valid
407    /// trading date. For times on one UTC day, older opens are omitted once the containing
408    /// session and any overlapping trading date have been visited. Days
409    /// far from every time are skipped, so the work follows the times, not the span between
410    /// them. An open that falls in a repeated hour is its first instant, and one that falls in
411    /// a skipped hour is the instant the clocks jump.
412    pub(crate) fn opens(
413        &self,
414        times: impl IntoIterator<Item = DateTime<Utc>>,
415    ) -> Vec<(DateTime<Utc>, NaiveDate)> {
416        // UTC days since the epoch: whole numbers, cheaper per bar than calendar dates.
417        let mut days: Vec<i64> = Vec::new();
418        let mut earliest = DateTime::<Utc>::MAX_UTC;
419        for time in times {
420            earliest = earliest.min(time);
421            let day = time.timestamp().div_euclid(86_400);
422            if days.last() != Some(&day) {
423                days.push(day);
424            }
425        }
426        days.sort_unstable();
427        days.dedup();
428        let cutoff = (days.len() == 1).then_some(earliest);
429        let dates = days
430            .into_iter()
431            .filter_map(|day| DateTime::from_timestamp(day * 86_400, 0))
432            .map(|start| start.date_naive());
433        // Disjoint, sorted ranges of days, so that each day is visited once.
434        let mut ranges: Vec<(NaiveDate, NaiveDate)> = Vec::new();
435        for date in dates {
436            let mut first = date
437                .checked_sub_days(Days::new(9))
438                .unwrap_or(NaiveDate::MIN);
439            if let Some(session) = &self.session {
440                let windows = || session.windows.iter().chain(&session.extended_windows);
441                let continuous = windows().next().is_none();
442                let scheduled = windows().any(|window| !window.days.is_empty());
443                if continuous || scheduled {
444                    while !self.trades_on(first) {
445                        let Some(previous) = first.pred_opt() else {
446                            break;
447                        };
448                        first = previous;
449                    }
450                }
451            }
452            let last = date.checked_add_days(Days::new(2)).unwrap_or(date);
453            match ranges.last_mut() {
454                Some((_, end)) if first <= *end => *end = last.max(*end),
455                _ => ranges.push((first, last)),
456            }
457        }
458        let days = ranges.into_iter().flat_map(|(first, last)| {
459            (0..=(last - first).num_days()).map(move |offset| last - Days::new(offset as u64))
460        });
461        let midnight = |date: NaiveDate| Some((self.instant(date.and_time(NaiveTime::MIN))?, date));
462        let opening = |date| match &self.session {
463            None => midnight(date),
464            Some(session) => {
465                if self.continuous_on(date) {
466                    return if session.holidays.contains(&date) {
467                        None
468                    } else {
469                        midnight(date)
470                    };
471                }
472                // All windows of one trading date share its earliest open, including split sessions.
473                let open = self
474                    .hours_on(date)
475                    .iter()
476                    .filter_map(|hours| self.window_open(date, hours))
477                    .min()?;
478                Some((open, date))
479            }
480        };
481        let mut opens: Vec<_> = days
482            .scan(None, |stop, date| {
483                if stop.is_some_and(|stop| date < stop) {
484                    return None;
485                }
486                let entry = opening(date);
487                if stop.is_none()
488                    && let Some((open, _)) = entry
489                    && cutoff.is_some_and(|time| open <= time)
490                {
491                    // An overnight window may open before the preceding date's daytime window.
492                    *stop = Some(self.wall_time(open).date());
493                }
494                Some(entry)
495            })
496            .flatten()
497            .collect();
498        opens.sort_by_key(|(open, _)| *open);
499        opens
500    }
501
502    /// The instant the session holding the intraday bar at `time` opened: the open of its trading
503    /// day, or with `split` of the day's stretch of extended or regular hours holding it, which
504    /// starts where the other kind of hours last closed. `None` without trading hours, on daily
505    /// and longer bars, whose profiles span several days, and with `split` for a bar outside the
506    /// hours after the other kind of hours closed.
507    pub(crate) fn session_open(&self, time: DateTime<Utc>, split: bool) -> Option<DateTime<Utc>> {
508        self.session.as_ref()?;
509        if self.timeframe.duration_ms() >= Timeframe::Day1.duration_ms() {
510            return None;
511        }
512        let opens = self.opens([time]);
513        let last = opens
514            .partition_point(|(open, _)| *open <= time)
515            .checked_sub(1)?;
516        let (open, date) = opens[last];
517        if !split || self.continuous_on(date) {
518            return Some(open);
519        }
520        let extended = self.is_extended(time);
521        let hours = self.hours_on(date);
522        let spans = hours
523            .iter()
524            .filter_map(|hours| Some((hours.extended == extended, self.window_span(date, hours)?)));
525        let resumed = spans
526            .clone()
527            .filter(|&(same, (_, close))| !same && close <= time)
528            .map(|(_, (_, close))| close)
529            .max();
530        let first = spans
531            .filter(|&(same, (open, close))| {
532                same && open <= time && resumed.is_none_or(|resumed| close > resumed)
533            })
534            .map(|(_, (open, _))| open)
535            .min()?;
536        Some(resumed.map_or(first, |resumed| first.max(resumed)))
537    }
538
539    /// The trading date of `time` among `opens`: that of the last open at or before it, or the
540    /// calendar date in the time zone before the first.
541    fn trading_date_in(
542        &self,
543        opens: &[(DateTime<Utc>, NaiveDate)],
544        time: DateTime<Utc>,
545    ) -> NaiveDate {
546        match opens.partition_point(|(open, _)| *open <= time) {
547            0 => self.wall_time(time).date(),
548            n => opens[n - 1].1,
549        }
550    }
551
552    fn session_break(&self, date: NaiveDate, ts: DateTime<Utc>) -> SessionBreak {
553        SessionBreak {
554            ts,
555            break_type: if self.timeframe.duration_ms() < Timeframe::Day1.duration_ms() {
556                SessionBreakType::Daily
557            } else if self.timeframe.duration_ms() == Timeframe::Day1.duration_ms() {
558                SessionBreakType::Weekly
559            } else if matches!(self.timeframe, Timeframe::Month1 | Timeframe::Months(_)) {
560                SessionBreakType::Yearly
561            } else {
562                SessionBreakType::Monthly
563            },
564            label: Some(date.format("%Y-%m-%d").to_string()),
565        }
566    }
567}
568
569impl SessionProvider for ExchangeSessionProvider {
570    fn session_break_between(
571        &self,
572        from: &DateTime<Utc>,
573        to: &DateTime<Utc>,
574    ) -> Option<SessionBreak> {
575        if self.timeframe.duration_ms() >= Timeframe::Day1.duration_ms() {
576            let date = self.trading_date(*to);
577            return (self.period(self.trading_date(*from)) != self.period(date))
578                .then(|| self.session_break(date, *to));
579        }
580        let opens = self.opens([*from, *to]);
581        let date = self.trading_date_in(&opens, *to);
582        (self.period(self.trading_date_in(&opens, *from)) != self.period(date))
583            .then(|| self.session_break(date, *to))
584    }
585
586    /// Finds the session opens around the bars' days once and places each bar among them.
587    fn session_breaks(&self, bars: &[Bar]) -> Vec<(usize, SessionBreak)> {
588        let dates: Vec<_> = if self.timeframe.duration_ms() >= Timeframe::Day1.duration_ms() {
589            bars.iter().map(|bar| self.trading_date(bar.time)).collect()
590        } else {
591            let opens = self.opens(bars.iter().map(|bar| bar.time));
592            bars.iter()
593                .map(|bar| self.trading_date_in(&opens, bar.time))
594                .collect()
595        };
596        (1..bars.len())
597            .filter(|&i| self.period(dates[i]) != self.period(dates[i - 1]))
598            .map(|i| (i, self.session_break(dates[i], bars[i].time)))
599            .collect()
600    }
601
602    fn name(&self) -> &str {
603        "Exchange Sessions"
604    }
605}
606
607#[cfg(all(test, feature = "timezones"))]
608mod tests {
609    use super::*;
610    use crate::model::session::find_session_breaks;
611    use chrono::{Duration, Weekday};
612
613    #[test]
614    fn calendar_dividers_follow_bar_dates_and_coarser_periods() {
615        for (timeframe, dates, expected, break_type) in [
616            (
617                "12month".parse().unwrap(),
618                &[
619                    (2015, 1, 1),
620                    (2016, 1, 1),
621                    (2017, 1, 1),
622                    (2018, 1, 1),
623                    (2019, 1, 1),
624                    (2020, 1, 1),
625                    (2021, 1, 1),
626                    (2022, 1, 1),
627                    (2023, 1, 1),
628                    (2024, 1, 1),
629                ][..],
630                &[][..],
631                SessionBreakType::Yearly,
632            ),
633            (
634                Timeframe::Day1,
635                &[
636                    (2024, 3, 7),
637                    (2024, 3, 8),
638                    (2024, 3, 11),
639                    (2024, 3, 12),
640                    (2024, 3, 13),
641                ][..],
642                &[(2, "2024-03-11")][..],
643                SessionBreakType::Weekly,
644            ),
645            // Monday July 8 starts week 28, after the Thursday and Friday bars.
646            (
647                Timeframe::Day1,
648                &[
649                    (2024, 7, 4),
650                    (2024, 7, 5),
651                    (2024, 7, 8),
652                    (2024, 7, 9),
653                    (2024, 7, 10),
654                ][..],
655                &[(2, "2024-07-08")][..],
656                SessionBreakType::Weekly,
657            ),
658            // The second Monday bar opens July, even east of UTC.
659            (
660                Timeframe::Week1,
661                &[(2024, 6, 24), (2024, 7, 1), (2024, 7, 8)][..],
662                &[(1, "2024-07-01")][..],
663                SessionBreakType::Monthly,
664            ),
665            // Amman repeats midnight on October 29; all three dates are in week 43.
666            (
667                Timeframe::Day1,
668                &[(2021, 10, 28), (2021, 10, 29), (2021, 10, 30)][..],
669                &[][..],
670                SessionBreakType::Weekly,
671            ),
672            // Tehran starts Monday at 01:00 after skipping midnight on March 22.
673            (
674                Timeframe::Day1,
675                &[
676                    (2021, 3, 19),
677                    (2021, 3, 20),
678                    (2021, 3, 21),
679                    (2021, 3, 22),
680                    (2021, 3, 23),
681                ][..],
682                &[(3, "2021-03-22")][..],
683                SessionBreakType::Weekly,
684            ),
685            (
686                Timeframe::Week1,
687                &[(2024, 1, 22), (2024, 1, 29), (2024, 2, 5)][..],
688                &[(2, "2024-02-05")][..],
689                SessionBreakType::Monthly,
690            ),
691            (
692                "2d".parse().unwrap(),
693                &[(2024, 1, 30), (2024, 2, 1), (2024, 2, 3)][..],
694                &[(1, "2024-02-01")][..],
695                SessionBreakType::Monthly,
696            ),
697            (
698                Timeframe::Month1,
699                &[(2024, 1, 1), (2024, 2, 1), (2024, 12, 1), (2025, 1, 1)][..],
700                &[(3, "2025-01-01")][..],
701                SessionBreakType::Yearly,
702            ),
703            (
704                "3month".parse().unwrap(),
705                &[
706                    (2024, 1, 1),
707                    (2024, 4, 1),
708                    (2024, 7, 1),
709                    (2024, 10, 1),
710                    (2025, 1, 1),
711                ][..],
712                &[(4, "2025-01-01")][..],
713                SessionBreakType::Yearly,
714            ),
715            (
716                "24h".parse().unwrap(),
717                &[(2024, 3, 14), (2024, 3, 18)][..],
718                &[(1, "2024-03-18")][..],
719                SessionBreakType::Weekly,
720            ),
721        ] {
722            for (display, exchange) in [
723                ("UTC", "UTC"),
724                ("America/New_York", "America/New_York"),
725                ("Pacific/Chatham", "Pacific/Chatham"),
726                ("Europe/London", "Europe/Berlin"),
727                ("Europe/Berlin", "Asia/Tokyo"),
728                ("Asia/Kolkata", "Asia/Tokyo"),
729                ("Asia/Tokyo", "Australia/Sydney"),
730                ("Asia/Tehran", "Asia/Tehran"),
731                ("UTC", "Asia/Amman"),
732                ("Asia/Amman", "Asia/Amman"),
733            ] {
734                // Bars stamped at UTC, display or exchange midnight.
735                let zone = TimezoneId::named(display).unwrap();
736                let exchange_zone = TimezoneId::named(exchange).unwrap();
737                let instrument = crate::model::Instrument::new("CALENDAR")
738                    .with_time_zone(exchange_zone.clone())
739                    .with_session(Session::new([]));
740                let clock = crate::model::BarClock::new(timeframe)
741                    .with_timezone(zone.clone())
742                    .with_session(
743                        instrument.time_zone.clone().unwrap(),
744                        instrument.session.clone().unwrap(),
745                        false,
746                    );
747                let providers = [
748                    clock.sessions(),
749                    #[cfg(feature = "widget")]
750                    crate::chart::rendering::exchange_session(
751                        Some(&instrument),
752                        None,
753                        &crate::scales::TimezoneMode::Timezone(zone),
754                        timeframe,
755                    ),
756                ];
757                for provider in providers {
758                    let dates: Vec<_> = dates
759                        .iter()
760                        .map(|&(year, month, day)| {
761                            NaiveDate::from_ymd_opt(year, month, day).unwrap()
762                        })
763                        .collect();
764                    for stamp in ["utc", "display", "exchange"] {
765                        let bars: Vec<_> = dates
766                            .iter()
767                            .map(|&date| {
768                                let midnight = date.and_time(NaiveTime::MIN);
769                                let open = match stamp {
770                                    "utc" => midnight.and_utc(),
771                                    "display" => clock.open_on_date(date).unwrap(),
772                                    // Include the second midnight when the exchange repeats it.
773                                    _ => exchange_zone
774                                        .from_local_datetime(&midnight)
775                                        .latest()
776                                        .map(|time| time.to_utc())
777                                        .or_else(|| local_instant(exchange_zone.clone(), midnight))
778                                        .unwrap(),
779                                };
780                                Bar::new(open, 1.0, 1.0, 1.0, 1.0, 1.0)
781                            })
782                            .collect();
783                        let breaks = find_session_breaks(&bars, &provider);
784                        assert!(breaks.iter().all(|(_, item)| item.break_type == break_type));
785                        assert_eq!(
786                            breaks
787                                .iter()
788                                .map(|(i, item)| (*i, item.label.as_deref().unwrap()))
789                                .collect::<Vec<_>>(),
790                            expected,
791                            "{timeframe} {display}/{exchange} {stamp}"
792                        );
793                        for (pair, date) in bars.windows(2).zip(&dates[1..]) {
794                            assert_eq!(
795                                provider.trading_date(pair[1].time),
796                                *date,
797                                "{timeframe} {display}/{exchange} {stamp} {}",
798                                pair[1].time
799                            );
800                            let item = provider.session_break_between(&pair[0].time, &pair[1].time);
801                            let batch = breaks.iter().find(|(_, item)| item.ts == pair[1].time);
802                            assert_eq!(
803                                item.as_ref().map(|item| (&item.label, item.break_type)),
804                                batch.map(|(_, item)| (&item.label, item.break_type))
805                            );
806                        }
807                    }
808                }
809            }
810        }
811    }
812
813    #[test]
814    fn exchange_dates_follow_dst_windows_and_holidays() {
815        use crate::model::SessionWindow;
816        use Weekday::*;
817        let clock = |hour, minute| NaiveTime::from_hms_opt(hour, minute, 0).unwrap();
818        let every_day = [Mon, Tue, Wed, Thu, Fri, Sat, Sun];
819        let window = |open, close| SessionWindow::new(every_day, clock(open, 30), clock(close, 0));
820        // Local midnight to the following midnight spans 23 or 25 hours on these dates.
821        for (zone, midnight, next, hours) in [
822            (
823                "America/New_York",
824                "2024-03-10T05:00:00Z",
825                "2024-03-11T04:00:00Z",
826                23,
827            ),
828            (
829                "America/New_York",
830                "2024-11-03T04:00:00Z",
831                "2024-11-04T05:00:00Z",
832                25,
833            ),
834            (
835                "Europe/Zurich",
836                "2024-03-30T23:00:00Z",
837                "2024-03-31T22:00:00Z",
838                23,
839            ),
840            (
841                "Europe/Zurich",
842                "2024-10-26T22:00:00Z",
843                "2024-10-27T23:00:00Z",
844                25,
845            ),
846        ] {
847            let provider = ExchangeSessionProvider::new(TimezoneId::named(zone).unwrap(), None);
848            let midnight: DateTime<Utc> = midnight.parse().unwrap();
849            let next: DateTime<Utc> = next.parse().unwrap();
850            assert_eq!(next - midnight, Duration::hours(hours));
851            for instant in [midnight, next] {
852                let event = provider
853                    .session_break_between(&(instant - Duration::seconds(1)), &instant)
854                    .unwrap();
855                assert_eq!(
856                    event.label,
857                    Some(provider.wall_time(instant).date().to_string())
858                );
859            }
860            assert!(
861                provider
862                    .session_break_between(&midnight, &(next - Duration::seconds(1)))
863                    .is_none()
864            );
865        }
866        // A missing 02:30 opens at the jump; a repeated open occurs only at its first instant.
867        for (zone, month, day, open_hour, instant) in [
868            ("America/New_York", 3, 10, 2, "2024-03-10T07:00:00Z"),
869            ("America/New_York", 11, 3, 1, "2024-11-03T05:30:00Z"),
870            ("Europe/Zurich", 3, 31, 2, "2024-03-31T01:00:00Z"),
871            ("Europe/Zurich", 10, 27, 2, "2024-10-27T00:30:00Z"),
872        ] {
873            let provider = ExchangeSessionProvider::new(
874                TimezoneId::named(zone).unwrap(),
875                Some(Session::new([window(open_hour, 23)])),
876            );
877            let instant: DateTime<Utc> = instant.parse().unwrap();
878            assert_eq!(
879                provider.wall_time(instant).date(),
880                chrono::NaiveDate::from_ymd_opt(2024, month, day).unwrap()
881            );
882            assert!(
883                provider
884                    .session_break_between(&(instant - Duration::seconds(1)), &instant)
885                    .is_some()
886            );
887            assert!(
888                provider
889                    .session_break_between(&instant, &(instant + Duration::hours(2)))
890                    .is_none()
891            );
892        }
893        // An overnight open in the repeated hour stays open when the clock moves back.
894        for (zone, hour, open, close) in [
895            (
896                "America/New_York",
897                1,
898                "2024-11-03T05:30:00Z",
899                "2024-11-04T06:00:00Z",
900            ),
901            (
902                "Europe/Zurich",
903                2,
904                "2024-10-27T00:30:00Z",
905                "2024-10-28T01:00:00Z",
906            ),
907        ] {
908            let provider = ExchangeSessionProvider::new(
909                TimezoneId::named(zone).unwrap(),
910                Some(Session::new([]).with_extended_windows([SessionWindow::new(
911                    [Mon],
912                    clock(hour, 30),
913                    clock(hour, 0),
914                )])),
915            );
916            let open: DateTime<Utc> = open.parse().unwrap();
917            let close: DateTime<Utc> = close.parse().unwrap();
918            for (time, expected) in [
919                (open - Duration::seconds(1), false),
920                (open, true),
921                (open + Duration::minutes(45), true),
922                (close - Duration::seconds(1), true),
923                (close, false),
924            ] {
925                assert_eq!(provider.is_extended(time), expected, "{zone} {time}");
926            }
927        }
928        let weekdays = [Mon, Tue, Wed, Thu, Fri];
929        let session = Session::new([SessionWindow::new(weekdays, clock(9, 30), clock(16, 0))])
930            .with_extended_windows([
931                SessionWindow::new(weekdays, clock(4, 0), clock(9, 30)),
932                SessionWindow::new(weekdays, clock(16, 0), clock(20, 0)),
933            ])
934            .with_holidays([chrono::NaiveDate::from_ymd_opt(2024, 7, 4).unwrap()]);
935        let provider = ExchangeSessionProvider::new(
936            TimezoneId::named("America/New_York").unwrap(),
937            Some(session),
938        );
939        let times = [
940            "2024-07-03T23:00:00Z",
941            "2024-07-04T08:00:00Z",
942            "2024-07-05T07:59:59Z",
943            "2024-07-05T08:00:00Z",
944            "2024-07-05T13:29:59Z",
945            "2024-07-05T13:30:00Z",
946            "2024-07-05T20:00:00Z",
947            "2024-07-06T00:00:00Z",
948        ];
949        let bars: Vec<_> = times
950            .iter()
951            .map(|time| Bar::new(time.parse().unwrap(), 1.0, 1.0, 1.0, 1.0, 1.0))
952            .collect();
953        assert_eq!(
954            find_session_breaks(&bars, &provider)
955                .iter()
956                .map(|(index, _)| *index)
957                .collect::<Vec<_>>(),
958            [3]
959        );
960        assert_eq!(
961            bars.iter()
962                .map(|bar| provider.is_extended(bar.time))
963                .collect::<Vec<_>>(),
964            [true, false, false, true, true, false, true, false]
965        );
966        assert_eq!(
967            bars.iter()
968                .map(|bar| provider.is_open(bar.time, false))
969                .collect::<Vec<_>>(),
970            [false, false, false, false, false, true, false, false]
971        );
972        assert_eq!(
973            bars.iter()
974                .map(|bar| provider.is_open(bar.time, true))
975                .collect::<Vec<_>>(),
976            [true, false, false, true, true, true, true, false]
977        );
978        use MarketSession::{Closed, Open, PostMarket, PreMarket};
979        assert_eq!(
980            bars.iter()
981                .map(|bar| provider.market_session(bar.time))
982                .collect::<Vec<_>>(),
983            [
984                PostMarket, Closed, Closed, PreMarket, PreMarket, Open, PostMarket, Closed
985            ]
986            .map(Some)
987        );
988        assert_eq!(
989            ExchangeSessionProvider::new(TimezoneId::utc(), None).market_session(bars[5].time),
990            None
991        );
992        let overnight_pre = ExchangeSessionProvider::new(
993            TimezoneId::utc(),
994            Some(
995                Session::new([SessionWindow::new([Fri], clock(9, 30), clock(16, 0))])
996                    .with_extended_windows([SessionWindow::new([Fri], clock(20, 0), clock(4, 0))]),
997            ),
998        );
999        for (time, state) in [
1000            ("2024-07-04T20:00:00Z", PreMarket),
1001            ("2024-07-05T03:59:59Z", PreMarket),
1002            ("2024-07-05T04:00:00Z", Closed),
1003            ("2024-07-05T09:29:59Z", Closed),
1004            ("2024-07-05T09:30:00Z", Open),
1005            ("2024-07-05T16:00:00Z", Closed),
1006        ] {
1007            assert_eq!(
1008                overnight_pre.market_session(time.parse().unwrap()),
1009                Some(state),
1010                "{time}"
1011            );
1012        }
1013        // Split regular windows do not create a second trading day after lunch.
1014        let split = ExchangeSessionProvider::new(
1015            TimezoneId::utc(),
1016            Some(Session::new([
1017                SessionWindow::new(weekdays, clock(9, 0), clock(11, 0)),
1018                SessionWindow::new(weekdays, clock(13, 0), clock(16, 0)),
1019            ])),
1020        );
1021        assert!(
1022            split
1023                .session_break_between(
1024                    &"2024-07-05T10:00:00Z".parse().unwrap(),
1025                    &"2024-07-05T13:00:00Z".parse().unwrap()
1026                )
1027                .is_none()
1028        );
1029        let overnight = ExchangeSessionProvider::new(
1030            TimezoneId::named("America/New_York").unwrap(),
1031            Some(Session::new([SessionWindow::new(
1032                weekdays,
1033                clock(18, 0),
1034                clock(17, 0),
1035            )])),
1036        );
1037        let open = "2024-03-10T22:00:00Z".parse().unwrap();
1038        assert_eq!(
1039            overnight
1040                .session_break_between(&(open - Duration::seconds(1)), &open)
1041                .unwrap()
1042                .label
1043                .as_deref(),
1044            Some("2024-03-11")
1045        );
1046        let weekly = ExchangeSessionProvider::new(
1047            TimezoneId::utc(),
1048            Some(
1049                Session::new([SessionWindow::new([Mon], clock(9, 0), clock(16, 0))]).with_holidays(
1050                    [4, 11, 18].map(|day| NaiveDate::from_ymd_opt(2024, 3, day).unwrap()),
1051                ),
1052            ),
1053        );
1054        assert_eq!(
1055            weekly.trading_date("2024-03-25T08:00:00Z".parse().unwrap()),
1056            NaiveDate::from_ymd_opt(2024, 2, 26).unwrap()
1057        );
1058        let regular = ExchangeSessionProvider::new(
1059            TimezoneId::named("America/New_York").unwrap(),
1060            Some(Session::new([SessionWindow::new(
1061                weekdays,
1062                clock(9, 30),
1063                clock(16, 0),
1064            )])),
1065        );
1066        for (open, timeframe, expected) in [
1067            (
1068                "2024-03-11T00:00:00Z",
1069                "24h".parse().unwrap(),
1070                Some("2024-03-11T20:00:00Z"),
1071            ),
1072            (
1073                "2024-03-08T00:00:00Z",
1074                Timeframe::Day1,
1075                Some("2024-03-08T21:00:00Z"),
1076            ),
1077            (
1078                "2024-03-11T00:00:00Z",
1079                Timeframe::Day1,
1080                Some("2024-03-11T20:00:00Z"),
1081            ),
1082            (
1083                "2024-03-11T00:00:00Z",
1084                Timeframe::Week1,
1085                Some("2024-03-15T20:00:00Z"),
1086            ),
1087            (
1088                "2024-03-01T00:00:00Z",
1089                Timeframe::Month1,
1090                Some("2024-03-29T20:00:00Z"),
1091            ),
1092            (
1093                "2024-03-04T00:00:00Z",
1094                "2w".parse().unwrap(),
1095                Some("2024-03-15T20:00:00Z"),
1096            ),
1097            (
1098                "2024-04-01T00:00:00Z",
1099                "3month".parse().unwrap(),
1100                Some("2024-06-28T20:00:00Z"),
1101            ),
1102            (
1103                "2024-03-11T19:59:00Z",
1104                Timeframe::Min1,
1105                Some("2024-03-11T20:00:00Z"),
1106            ),
1107            ("2024-03-11T20:00:00Z", Timeframe::Min1, None),
1108            ("2024-03-10T00:00:00Z", Timeframe::Day1, None),
1109        ] {
1110            assert_eq!(
1111                regular.bar_session_close(open.parse().unwrap(), timeframe, false),
1112                expected.map(|time| time.parse().unwrap())
1113            );
1114        }
1115        for zone in ["America/New_York", "Europe/Zurich"] {
1116            let provider = ExchangeSessionProvider::new(TimezoneId::named(zone).unwrap(), None);
1117            for time in [DateTime::<Utc>::MIN_UTC, DateTime::<Utc>::MAX_UTC] {
1118                let _ = provider.session_break_between(&time, &time);
1119            }
1120        }
1121    }
1122
1123    #[test]
1124    fn date_specific_hours_replace_weekly_hours_and_holidays_across_dst() {
1125        use crate::model::{BarClock, OverrideWindow, SessionOverride, SessionWindow};
1126        use Weekday::*;
1127        let clock = |hour, minute| NaiveTime::from_hms_opt(hour, minute, 0).unwrap();
1128        let date = |text: &str| text.parse::<NaiveDate>().unwrap();
1129        let at = |text: &str| text.parse::<DateTime<Utc>>().unwrap();
1130        // Mon-Fri 09:30-16:00 New York time. Fri 1 Nov closes early at 13:00, with post-market
1131        // hours until 17:00. Sun 3 Nov, when clocks go back, holds an exceptional session
1132        // 10:00-12:00. Tue 5 Nov is a listed holiday with exceptional hours that win, and Wed 6
1133        // Nov is closed by an override without windows.
1134        let session = Session::new([SessionWindow::new(
1135            [Mon, Tue, Wed, Thu, Fri],
1136            clock(9, 30),
1137            clock(16, 0),
1138        )])
1139        .with_holidays([date("2024-11-05")])
1140        .with_overrides([
1141            SessionOverride::new(date("2024-11-01"))
1142                .with_windows([OverrideWindow::new(clock(9, 30), clock(13, 0))])
1143                .with_extended_windows([OverrideWindow::new(clock(13, 0), clock(17, 0))]),
1144            SessionOverride::new(date("2024-11-03"))
1145                .with_windows([OverrideWindow::new(clock(10, 0), clock(12, 0))]),
1146            SessionOverride::new(date("2024-11-05"))
1147                .with_windows([OverrideWindow::new(clock(9, 30), clock(11, 0))]),
1148            SessionOverride::new(date("2024-11-06")),
1149        ]);
1150        let provider = ExchangeSessionProvider::new(
1151            TimezoneId::named("America/New_York").unwrap(),
1152            Some(session),
1153        );
1154        // (instant, open regular, open with extended, extended shading, close with extended)
1155        for (time, regular, extended, shaded, close) in [
1156            // Fri 1 Nov, UTC-4: 12:59 is open, 13:00 closes the regular window.
1157            (
1158                "2024-11-01T16:59:00Z",
1159                true,
1160                true,
1161                false,
1162                "2024-11-01T17:00:00Z",
1163            ),
1164            (
1165                "2024-11-01T17:00:00Z",
1166                false,
1167                true,
1168                true,
1169                "2024-11-01T21:00:00Z",
1170            ),
1171            ("2024-11-01T21:00:00Z", false, false, false, ""),
1172            // Sun 3 Nov, UTC-5 from 06:00Z: 10:00 is 15:00Z and 12:00 is 17:00Z.
1173            ("2024-11-03T14:59:00Z", false, false, false, ""),
1174            (
1175                "2024-11-03T15:00:00Z",
1176                true,
1177                true,
1178                false,
1179                "2024-11-03T17:00:00Z",
1180            ),
1181            ("2024-11-03T17:00:00Z", false, false, false, ""),
1182            // Mon 4 Nov follows the weekly hours, now in UTC-5.
1183            ("2024-11-04T21:00:00Z", false, false, false, ""),
1184            (
1185                "2024-11-04T20:59:00Z",
1186                true,
1187                true,
1188                false,
1189                "2024-11-04T21:00:00Z",
1190            ),
1191            // Tue 5 Nov is a holiday, yet its override opens 09:30-11:00.
1192            (
1193                "2024-11-05T14:30:00Z",
1194                true,
1195                true,
1196                false,
1197                "2024-11-05T16:00:00Z",
1198            ),
1199            ("2024-11-05T16:00:00Z", false, false, false, ""),
1200            // Wed 6 Nov is closed although the weekly hours are open.
1201            ("2024-11-06T15:00:00Z", false, false, false, ""),
1202            (
1203                "2024-11-07T15:00:00Z",
1204                true,
1205                true,
1206                false,
1207                "2024-11-07T21:00:00Z",
1208            ),
1209        ] {
1210            let time = at(time);
1211            assert_eq!(provider.is_open(time, false), regular, "{time}");
1212            assert_eq!(provider.is_open(time, true), extended, "{time}");
1213            assert_eq!(provider.is_extended(time), shaded, "{time}");
1214            assert_eq!(
1215                provider.market_session(time),
1216                Some(if regular {
1217                    MarketSession::Open
1218                } else if extended {
1219                    MarketSession::PostMarket
1220                } else {
1221                    MarketSession::Closed
1222                }),
1223                "{time}"
1224            );
1225            assert_eq!(
1226                provider.session_close(time, true),
1227                (!close.is_empty()).then(|| at(close)),
1228                "{time}"
1229            );
1230        }
1231        // History boundaries: each exceptional day starts a trading day of its own at its open,
1232        // and a closed day has none, so its hours stay with the day before.
1233        for (time, trading_date) in [
1234            ("2024-11-03T15:30:00Z", "2024-11-03"),
1235            ("2024-11-04T15:00:00Z", "2024-11-04"),
1236            ("2024-11-05T15:00:00Z", "2024-11-05"),
1237            ("2024-11-06T15:00:00Z", "2024-11-05"),
1238            ("2024-11-07T15:00:00Z", "2024-11-07"),
1239        ] {
1240            assert_eq!(
1241                provider.trading_date(at(time)),
1242                date(trading_date),
1243                "{time}"
1244            );
1245        }
1246        // Daily bars take the close of their own trading date.
1247        for (open, close) in [
1248            ("2024-11-01T00:00:00Z", "2024-11-01T21:00:00Z"),
1249            ("2024-11-03T00:00:00Z", "2024-11-03T17:00:00Z"),
1250        ] {
1251            assert_eq!(
1252                provider.bar_session_close(at(open), Timeframe::Day1, true),
1253                Some(at(close))
1254            );
1255        }
1256        // Bar clock: hourly bars of the exceptional Sunday start at its open.
1257        let bars = BarClock::new(Timeframe::Hour1).with_session(
1258            TimezoneId::named("America/New_York").unwrap(),
1259            Session::new([SessionWindow::new([Mon], clock(9, 30), clock(16, 0))])
1260                .with_overrides([SessionOverride::new(date("2024-11-03"))
1261                    .with_windows([OverrideWindow::new(clock(10, 0), clock(12, 0))])]),
1262            false,
1263        );
1264        assert_eq!(
1265            bars.bar_open(at("2024-11-03T16:30:00Z")),
1266            at("2024-11-03T16:00:00Z")
1267        );
1268        assert_eq!(
1269            bars.bar_open(at("2024-11-03T15:30:00Z")),
1270            at("2024-11-03T15:00:00Z")
1271        );
1272    }
1273}