1use 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
7struct 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 fn overnight(&self) -> bool {
25 self.close <= self.open && self.open != NaiveTime::MIN
26 }
27}
28
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32#[cfg(any(feature = "widget", all(test, feature = "timezones")))]
33pub(crate) enum MarketSession {
34 Open,
36 PreMarket,
38 PostMarket,
40 Closed,
42}
43
44#[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 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 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 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 pub fn with_timeframe(mut self, timeframe: Timeframe) -> Self {
117 self.timeframe = timeframe;
118 self
119 }
120
121 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 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 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 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 #[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 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 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 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 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 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 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 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 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 let close = self.close_instant(end_date.and_time(hours.close))?;
356 Some((self.window_open(close_date, hours)?, close))
357 }
358
359 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 pub(crate) fn opens(
413 &self,
414 times: impl IntoIterator<Item = DateTime<Utc>>,
415 ) -> Vec<(DateTime<Utc>, NaiveDate)> {
416 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 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 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 *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 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 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 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 (
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 (
660 Timeframe::Week1,
661 &[(2024, 6, 24), (2024, 7, 1), (2024, 7, 8)][..],
662 &[(1, "2024-07-01")][..],
663 SessionBreakType::Monthly,
664 ),
665 (
667 Timeframe::Day1,
668 &[(2021, 10, 28), (2021, 10, 29), (2021, 10, 30)][..],
669 &[][..],
670 SessionBreakType::Weekly,
671 ),
672 (
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 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 _ => 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 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 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 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 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 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 for (time, regular, extended, shaded, close) in [
1156 (
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 ("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 ("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 (
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 ("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 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 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 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}