Skip to main content

egui_charts/model/
clock.rs

1//! Bar boundaries in time.
2
3use super::{Timeframe, TimezoneId};
4use chrono::{
5    DateTime, Datelike, LocalResult, NaiveDate, NaiveTime, Offset, TimeDelta, TimeZone, Utc,
6};
7
8const DAY_MS: i64 = 86_400_000;
9/// 1970-01-05, the first Monday after the Unix epoch.
10const FIRST_MONDAY_MS: i64 = 4 * DAY_MS;
11
12/// Bar boundaries for one timeframe, around the clock in UTC by default.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct BarClock {
15    /// `None` for a clock that matches bars by their exact open time.
16    timeframe: Option<Timeframe>,
17    timezone: Option<TimezoneId>,
18    session: Option<super::ExchangeSessionProvider>,
19}
20
21impl BarClock {
22    /// Clock for bars of `timeframe`.
23    pub fn new(timeframe: Timeframe) -> Self {
24        Self {
25            timeframe: Some(timeframe),
26            timezone: Some(TimezoneId::utc()),
27            session: None,
28        }
29    }
30
31    /// Align time windows to the local calendar of `timezone`. Repeated clock hours remain
32    /// separate; a boundary skipped by a clock change starts at the first valid local instant.
33    pub fn with_timezone(mut self, timezone: TimezoneId) -> Self {
34        self.timezone = Some(timezone);
35        self
36    }
37
38    /// Align time windows to the system's local calendar.
39    pub fn with_local_timezone(mut self) -> Self {
40        self.timezone = None;
41        self
42    }
43
44    /// Align intraday buckets to the first selected exchange-session window. Calendar bars
45    /// keep their trading-date labels; use `data::resample_with_session` to derive those labels
46    /// from intraday history. Extended windows participate only when requested.
47    pub fn with_session(
48        mut self,
49        zone: super::TimezoneId,
50        mut session: super::Session,
51        extended_hours: bool,
52    ) -> Self {
53        if !extended_hours {
54            session.clear_extended_windows();
55        }
56        self.session = Some(super::ExchangeSessionProvider::new(zone, Some(session)));
57        self
58    }
59
60    /// Clock in which every instant opens a bar of its own, for bars of an unknown timeframe.
61    #[cfg_attr(
62        not(any(feature = "widget", feature = "alert-engine")),
63        expect(dead_code, reason = "only charts and alerts read unaggregated bars")
64    )]
65    pub(crate) fn exact() -> Self {
66        Self {
67            timeframe: None,
68            timezone: Some(TimezoneId::utc()),
69            session: None,
70        }
71    }
72
73    /// Open time of the bar containing `t`. Bars shorter than a day align to local midnight,
74    /// weekly bars to Monday, monthly bars to the first day of the month, and other bars to the
75    /// Unix epoch. Trade-based bars retain their exact opening timestamp.
76    pub fn bar_open(&self, t: DateTime<Utc>) -> DateTime<Utc> {
77        let Some(timeframe) = self.timeframe else {
78            return t;
79        };
80        if timeframe.is_trade_based() {
81            return t;
82        }
83        if timeframe.duration_ms() < DAY_MS
84            && let Some(session) = &self.session
85        {
86            let opens = session.opens([t]);
87            let n = opens.partition_point(|(open, _)| *open <= t);
88            if let Some((open, _)) = n.checked_sub(1).and_then(|i| opens.get(i)) {
89                let elapsed = (t - *open).num_milliseconds();
90                let bucket = open
91                    .checked_add_signed(TimeDelta::milliseconds(
92                        elapsed - elapsed.rem_euclid(timeframe.duration_ms()),
93                    ))
94                    .unwrap_or(t);
95                // Touching selected windows share a bucket; only a gap closes it.
96                let mut end = session.session_close(bucket, true);
97                while let Some(close) = end.filter(|close| *close <= t) {
98                    end = session.session_close(close, true);
99                    if end.is_none() {
100                        return close;
101                    }
102                }
103                return bucket;
104            }
105        }
106        let offset = self.offset_at(t);
107        let local = t
108            .naive_utc()
109            .checked_add_signed(TimeDelta::seconds(i64::from(offset)))
110            .unwrap_or(t.naive_utc());
111        let ms = local.and_utc().timestamp_millis();
112        let (period, anchor) = match timeframe {
113            Timeframe::Ticks(_) => return t,
114            Timeframe::Month1 => {
115                let first = local.date().with_day(1).unwrap().and_time(NaiveTime::MIN);
116                return self.local_boundary(first, t);
117            }
118            Timeframe::Months(count) => {
119                let month = i64::from(local.year() - 1970) * 12 + i64::from(local.month0());
120                let month = month - month.rem_euclid(i64::from(count.get()));
121                let first = i32::try_from(1970 + month.div_euclid(12))
122                    .ok()
123                    .and_then(|year| {
124                        NaiveDate::from_ymd_opt(year, month.rem_euclid(12) as u32 + 1, 1)
125                    });
126                return first.map_or(t, |date| {
127                    self.local_boundary(date.and_time(NaiveTime::MIN), t)
128                });
129            }
130            Timeframe::Week1 => (7 * DAY_MS, FIRST_MONDAY_MS),
131            Timeframe::Weeks(count) => (i64::from(count.get()) * 7 * DAY_MS, FIRST_MONDAY_MS),
132            timeframe => {
133                let period = timeframe.duration_ms().max(1);
134                let midnight = ms - ms.rem_euclid(DAY_MS);
135                (period, if period < DAY_MS { midnight } else { 0 })
136            }
137        };
138        let boundary = DateTime::from_timestamp_millis(ms - (ms - anchor).rem_euclid(period))
139            .map_or(local, |time| time.naive_utc());
140        let open = self.local_boundary(boundary, t);
141        // A repeated hour can begin inside a short bucket whose nominal open is before it.
142        if timeframe.duration() <= TimeDelta::hours(1) && t - open >= timeframe.duration() {
143            let mut lo = t
144                .checked_sub_signed(timeframe.duration())
145                .unwrap_or(t)
146                .timestamp_millis();
147            let mut hi = t.timestamp_millis();
148            if self.offset_at(DateTime::from_timestamp_millis(lo).unwrap_or(t)) != offset {
149                while hi - lo > 1 {
150                    let mid = lo + (hi - lo) / 2;
151                    if self.offset_at(DateTime::from_timestamp_millis(mid).unwrap_or(t)) == offset {
152                        hi = mid;
153                    } else {
154                        lo = mid;
155                    }
156                }
157                return DateTime::from_timestamp_millis(hi).unwrap_or(open);
158            }
159        }
160        if period > TimeDelta::hours(1).num_milliseconds()
161            && period < DAY_MS
162            && let Some(next) = boundary.checked_add_signed(timeframe.duration())
163        {
164            // The first pass of the next boundary may already have opened a bar.
165            let next = self.local_boundary(next, open);
166            if next > open && next <= t {
167                return next;
168            }
169        }
170        open
171    }
172
173    fn offset_at(&self, t: DateTime<Utc>) -> i32 {
174        match &self.timezone {
175            Some(zone) => t.with_timezone(zone).offset().fix().local_minus_utc(),
176            None => t.with_timezone(&chrono::Local).offset().local_minus_utc(),
177        }
178    }
179
180    fn local_boundary(&self, local: chrono::NaiveDateTime, at: DateTime<Utc>) -> DateTime<Utc> {
181        let time = match &self.timezone {
182            Some(zone) => zone.from_local_datetime(&local).map(|time| time.to_utc()),
183            None => chrono::Local
184                .from_local_datetime(&local)
185                .map(|time| time.to_utc()),
186        };
187        match time {
188            LocalResult::Single(time) => time,
189            LocalResult::Ambiguous(first, second) => {
190                // Calendar periods keep one open when midnight repeats.
191                if second <= at && self.timeframe.is_some_and(|tf| tf.duration_ms() < DAY_MS) {
192                    second
193                } else {
194                    first
195                }
196            }
197            LocalResult::None => match &self.timezone {
198                Some(zone) => super::local_instant(zone.clone(), local),
199                None => super::local_instant(chrono::Local, local),
200            }
201            .unwrap_or(at),
202        }
203    }
204
205    /// First boundary after an aligned open, including short days and calendar months.
206    pub(crate) fn next_open(&self, open: DateTime<Utc>) -> Option<DateTime<Utc>> {
207        let timeframe = self.timeframe?;
208        if timeframe.is_trade_based() {
209            return None;
210        }
211        if timeframe.duration_ms() < DAY_MS
212            && let Some(session) = &self.session
213        {
214            let opens = session.opens([open]);
215            let n = opens.partition_point(|(start, _)| *start <= open);
216            if let Some((start, _)) = n.checked_sub(1).and_then(|i| opens.get(i)) {
217                let remaining = timeframe.duration_ms()
218                    - (open - *start)
219                        .num_milliseconds()
220                        .rem_euclid(timeframe.duration_ms());
221                let mut next = open.checked_add_signed(TimeDelta::milliseconds(remaining))?;
222                if let Some((start, _)) = opens.get(n) {
223                    next = next.min(*start);
224                }
225                // Touching selected windows keep the bucket open until the grid or a gap.
226                let mut end = session.session_close(open, true);
227                while let Some(close) = end.filter(|close| *close < next) {
228                    end = session.session_close(close, true);
229                    if end.is_none() {
230                        next = close;
231                    }
232                }
233                return Some(next);
234            }
235        }
236        if let Timeframe::Months(count) = timeframe {
237            let local = open
238                .naive_utc()
239                .checked_add_signed(TimeDelta::seconds(i64::from(self.offset_at(open))))?;
240            let next = local
241                .date()
242                .checked_add_months(chrono::Months::new(count.get()))?
243                .and_time(NaiveTime::MIN);
244            return Some(self.local_boundary(next, open));
245        }
246        let mut at = open.checked_add_signed(timeframe.duration())?;
247        loop {
248            let next = self.bar_open(at);
249            if next > open {
250                if self.bar_open(next - TimeDelta::milliseconds(1)) == open {
251                    return Some(next);
252                }
253                let mut lo = open.timestamp_millis();
254                let mut hi = at.timestamp_millis();
255                while hi - lo > 1 {
256                    let mid = lo + (hi - lo) / 2;
257                    let time = DateTime::from_timestamp_millis(mid)?;
258                    if self.bar_open(time) > open {
259                        hi = mid;
260                    } else {
261                        lo = mid;
262                    }
263                }
264                return Some(self.bar_open(DateTime::from_timestamp_millis(hi)?));
265            }
266            at = at.checked_add_signed(TimeDelta::hours(1))?;
267        }
268    }
269
270    #[cfg(feature = "studies")]
271    pub(crate) fn for_timeframe(&self, timeframe: Timeframe) -> Self {
272        Self {
273            timeframe: Some(timeframe),
274            ..self.clone()
275        }
276    }
277
278    /// Label a trading date in this clock's calendar before finding its containing period.
279    pub(crate) fn open_on_date(&self, date: chrono::NaiveDate) -> Option<DateTime<Utc>> {
280        let midnight = date.and_time(NaiveTime::MIN);
281        let time = match &self.timezone {
282            Some(zone) => super::local_instant(zone.clone(), midnight),
283            None => super::local_instant(chrono::Local, midnight),
284        }?;
285        Some(self.bar_open(time))
286    }
287
288    /// The timeframe of the bars, `None` for a clock that matches bars by their exact open time.
289    pub(crate) fn timeframe(&self) -> Option<Timeframe> {
290        self.timeframe
291    }
292
293    /// The trading days of this clock's bars: its exchange sessions, or calendar days in its
294    /// time zone without them.
295    pub(crate) fn sessions(&self) -> super::ExchangeSessionProvider {
296        let sessions = match (&self.session, &self.timezone) {
297            (Some(session), _) => session.clone(),
298            (None, Some(zone)) => super::ExchangeSessionProvider::new(zone.clone(), None),
299            (None, None) => super::ExchangeSessionProvider::local(None),
300        };
301        sessions
302            .with_calendar_zone(self.timezone.clone())
303            .with_timeframe(self.timeframe.unwrap_or(Timeframe::Min1))
304    }
305
306    /// Whether this clock restricts bars to exchange trading windows.
307    pub fn has_session(&self) -> bool {
308        self.session.is_some()
309    }
310
311    /// Whether `t` is inside a window of this clock's session, always without a session.
312    /// [`with_session`](Self::with_session) keeps only the windows the subscription selected.
313    pub(crate) fn is_open(&self, t: DateTime<Utc>) -> bool {
314        self.session.as_ref().is_none_or(|s| s.is_open(t, true))
315    }
316
317    /// Whether `a` and `b` fall in the same bar, so a feed may stamp a bar with any instant
318    /// inside it.
319    pub fn same_bar(&self, a: DateTime<Utc>, b: DateTime<Utc>) -> bool {
320        self.bar_open(a) == self.bar_open(b)
321    }
322}
323
324impl From<Timeframe> for BarClock {
325    fn from(timeframe: Timeframe) -> Self {
326        Self::new(timeframe)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn t(rfc3339: &str) -> DateTime<Utc> {
335        rfc3339.parse().unwrap()
336    }
337
338    /// Calendar timeframes must not fall back to fixed durations from the epoch: that gives
339    /// Thursday weeks, 30-day months and intraday bars that ignore midnight.
340    #[test]
341    fn bars_open_at_calendar_boundaries() {
342        #[rustfmt::skip]
343        let cases = [
344            (Timeframe::Min1, "2024-03-15T10:07:42.5Z", "2024-03-15T10:07:00Z"),
345            (Timeframe::Hour4, "2024-03-15T23:30:00Z", "2024-03-15T20:00:00Z"),
346            // Seven-minute bars restart at midnight, so the last one of the day is short.
347            ("7min".parse().unwrap(), "2024-03-15T23:58:00Z", "2024-03-15T23:55:00Z"),
348            (Timeframe::Day1, "2024-03-15T23:59:59Z", "2024-03-15T00:00:00Z"),
349            (Timeframe::Day1, "1969-07-20T20:17:40Z", "1969-07-20T00:00:00Z"),
350            // A Sunday belongs to the week that opened on the Monday before.
351            (Timeframe::Week1, "2024-03-17T12:00:00Z", "2024-03-11T00:00:00Z"),
352            (Timeframe::Month1, "2024-02-29T13:00:00Z", "2024-02-01T00:00:00Z"),
353            ("3month".parse().unwrap(), "2024-04-15T12:00:00Z", "2024-04-01T00:00:00Z"),
354            ("3month".parse().unwrap(), "2024-02-29T12:00:00Z", "2024-01-01T00:00:00Z"),
355            ("3month".parse().unwrap(), "2024-07-15T12:00:00Z", "2024-07-01T00:00:00Z"),
356            ("3month".parse().unwrap(), "2024-12-31T23:59:59Z", "2024-10-01T00:00:00Z"),
357            ("6month".parse().unwrap(), "2024-12-31T23:59:59Z", "2024-07-01T00:00:00Z"),
358            ("12month".parse().unwrap(), "2024-12-31T23:59:59Z", "2024-01-01T00:00:00Z"),
359            ("3month".parse().unwrap(), "1969-12-31T23:59:59Z", "1969-10-01T00:00:00Z"),
360            ("2w".parse().unwrap(), "1970-01-04T12:00:00Z", "1969-12-22T00:00:00Z"),
361        ];
362        for (timeframe, at, open) in cases {
363            let clock = BarClock::new(timeframe);
364            assert_eq!(clock.bar_open(t(at)), t(open), "{timeframe} at {at}");
365        }
366        for (timeframe, at, open, next, zone) in [
367            (
368                "90min".parse().unwrap(),
369                "2024-11-03T06:10:00Z",
370                "2024-11-03T05:30:00Z",
371                "2024-11-03T06:30:00Z",
372                "America/New_York",
373            ),
374            (
375                "3month".parse().unwrap(),
376                "2024-04-01T03:59:59Z",
377                "2024-01-01T05:00:00Z",
378                "2024-04-01T04:00:00Z",
379                "America/New_York",
380            ),
381            (
382                "3month".parse().unwrap(),
383                "2024-04-15T12:00:00Z",
384                "2024-04-01T04:00:00Z",
385                "2024-07-01T04:00:00Z",
386                "America/New_York",
387            ),
388            (
389                "2w".parse().unwrap(),
390                "2024-03-15T12:00:00Z",
391                "2024-03-04T05:00:00Z",
392                "2024-03-18T04:00:00Z",
393                "America/New_York",
394            ),
395            (
396                "123s".parse().unwrap(),
397                "2024-03-10T07:00:01Z",
398                "2024-03-10T07:00:00Z",
399                "2024-03-10T07:00:24Z",
400                "America/New_York",
401            ),
402            (
403                "45min".parse().unwrap(),
404                "2024-11-03T06:01:00Z",
405                "2024-11-03T06:00:00Z",
406                "2024-11-03T06:30:00Z",
407                "America/New_York",
408            ),
409            (
410                "45min".parse().unwrap(),
411                "2024-11-03T05:45:00Z",
412                "2024-11-03T05:30:00Z",
413                "2024-11-03T06:00:00Z",
414                "America/New_York",
415            ),
416            (
417                "7min".parse().unwrap(),
418                "2024-03-10T06:59:30Z",
419                "2024-03-10T06:59:00Z",
420                "2024-03-10T07:00:00Z",
421                "America/New_York",
422            ),
423            (
424                Timeframe::Hour4,
425                "2024-03-10T07:30:00Z",
426                "2024-03-10T05:00:00Z",
427                "2024-03-10T08:00:00Z",
428                "America/New_York",
429            ),
430            (
431                Timeframe::Hour4,
432                "2024-11-03T08:30:00Z",
433                "2024-11-03T04:00:00Z",
434                "2024-11-03T09:00:00Z",
435                "America/New_York",
436            ),
437            (
438                Timeframe::Hour1,
439                "2024-11-03T05:30:00Z",
440                "2024-11-03T05:00:00Z",
441                "2024-11-03T06:00:00Z",
442                "America/New_York",
443            ),
444            (
445                Timeframe::Hour1,
446                "2024-11-03T06:30:00Z",
447                "2024-11-03T06:00:00Z",
448                "2024-11-03T07:00:00Z",
449                "America/New_York",
450            ),
451            (
452                Timeframe::Month1,
453                "2024-03-15T12:00:00Z",
454                "2024-03-01T05:00:00Z",
455                "2024-04-01T04:00:00Z",
456                "America/New_York",
457            ),
458            (
459                "2month".parse().unwrap(),
460                "2020-11-01T05:30:00Z",
461                "2020-11-01T04:00:00Z",
462                "2021-01-01T05:00:00Z",
463                "America/Havana",
464            ),
465        ] {
466            let Some(zone) = TimezoneId::named(zone) else {
467                continue;
468            };
469            let clock = BarClock::new(timeframe).with_timezone(zone);
470            assert_eq!(clock.bar_open(t(at)), t(open));
471            assert_eq!(clock.next_open(t(open)), Some(t(next)));
472        }
473        let clock = BarClock::new("7min".parse().unwrap()).with_session(
474            super::super::TimezoneId::utc(),
475            super::super::Session::new([]),
476            false,
477        );
478        assert_eq!(
479            clock.next_open(t("2024-03-15T23:55:00Z")),
480            Some(t("2024-03-16T00:00:00Z"))
481        );
482    }
483
484    #[cfg(feature = "timezones")]
485    #[test]
486    fn intraday_bars_partition_repeated_hours() {
487        for (zone, transition, intervals) in [
488            (
489                "America/New_York",
490                "2024-11-03T06:00:00Z",
491                &[61, 75, 80, 90, 100][..],
492            ),
493            (
494                "America/Chicago",
495                "2024-11-03T07:00:00Z",
496                &[61, 75, 80, 100][..],
497            ),
498            ("Europe/London", "2024-10-27T01:00:00Z", &[90][..]),
499            ("America/St_Johns", "2024-11-03T04:30:00Z", &[90][..]),
500            (
501                "Europe/Berlin",
502                "2024-10-27T01:00:00Z",
503                &[61, 75, 80, 150][..],
504            ),
505            (
506                "Australia/Sydney",
507                "2024-04-06T16:00:00Z",
508                &[61, 75, 80, 150][..],
509            ),
510            (
511                "Europe/Chisinau",
512                "2024-10-27T00:00:00Z",
513                &[61, 75, 80, 150][..],
514            ),
515            (
516                "Pacific/Chatham",
517                "2024-04-06T14:00:00Z",
518                &[61, 100, 180][..],
519            ),
520            (
521                "America/Sao_Paulo",
522                "2018-02-18T02:00:00Z",
523                &[61, 75, 100][..],
524            ),
525        ] {
526            for &minutes in intervals {
527                let clock = BarClock::new(format!("{minutes}min").parse().unwrap())
528                    .with_timezone(TimezoneId::named(zone).unwrap());
529                let start = t(transition) - TimeDelta::hours(14);
530                let mut previous = clock.bar_open(start);
531                let mut trades = Vec::new();
532                for step in 0..=240 {
533                    let at = start + TimeDelta::minutes(step * 10);
534                    let open = clock.bar_open(at);
535                    let next = clock.next_open(open).unwrap();
536                    assert!(
537                        previous <= open && open <= at && at < next,
538                        "{zone} {minutes}min {at}: {open}..{next}"
539                    );
540                    assert_eq!(clock.bar_open(open), open);
541                    assert_eq!(clock.bar_open(next), next);
542                    assert_eq!(clock.bar_open(next - TimeDelta::milliseconds(1)), open);
543                    previous = open;
544                    trades.push(crate::model::Tick::new(at, 100.0, 1.0, None));
545                }
546                assert!(crate::data::aggregate_trades(&trades, clock).is_ok());
547            }
548        }
549    }
550
551    #[cfg(feature = "timezones")]
552    #[test]
553    fn closed_session_ticks_open_separate_bars() {
554        use crate::model::{Session, SessionWindow};
555        use chrono::Weekday::*;
556        for (close_minute, split, after, expected) in [
557            (0, false, "2024-03-12T20:10:00Z", "2024-03-12T20:00:00Z"),
558            (15, false, "2024-03-12T20:20:00Z", "2024-03-12T20:15:00Z"),
559            (15, true, "2024-03-12T20:20:00Z", "2024-03-12T20:15:00Z"),
560        ] {
561            let mut session = Session::new([SessionWindow::new(
562                [Mon, Tue, Wed, Thu, Fri],
563                NaiveTime::from_hms_opt(9, 30, 0).unwrap(),
564                NaiveTime::from_hms_opt(16, close_minute, 0).unwrap(),
565            )]);
566            if split {
567                let boundary = NaiveTime::from_hms_opt(11, 15, 0).unwrap();
568                session.windows[0].close = boundary;
569                session.windows.push(SessionWindow::new(
570                    [Mon, Tue, Wed, Thu, Fri],
571                    boundary,
572                    NaiveTime::from_hms_opt(16, close_minute, 0).unwrap(),
573                ));
574            }
575            let clock = BarClock::new(Timeframe::Hour1).with_session(
576                TimezoneId::named("America/New_York").unwrap(),
577                session,
578                false,
579            );
580            assert_eq!(clock.bar_open(t(after)), t(expected));
581            let inbox = std::sync::Arc::new(crate::data::inbox::Inbox::default());
582            let sink = inbox.subscribe("SAMPLE".into(), clock.clone(), 100, None);
583            for time in ["2024-03-12T19:59:00Z", after] {
584                sink.push_tick(crate::model::Tick::new(t(time), 100.0, 1.0, None));
585            }
586            let (_, updates) = inbox.drain().updates.pop().unwrap();
587            assert_eq!(
588                updates.bars.keys().copied().collect::<Vec<_>>(),
589                [t("2024-03-12T19:30:00Z"), t(expected)]
590            );
591            assert!(updates.bars.values().all(|(bar, _)| bar.volume == 1.0));
592            // Include the opening, the shortened last bar and the following off-session bar.
593            for step in 0..=600 {
594                let at = t("2024-03-12T13:00:00Z") + TimeDelta::minutes(step);
595                let open = clock.bar_open(at);
596                let next = clock.next_open(open).unwrap();
597                assert!(open <= at && at < next, "{at}: {open}..{next}");
598                assert_eq!(clock.bar_open(next - TimeDelta::milliseconds(1)), open);
599            }
600        }
601        // Tuesday's overnight window opens before Monday's late daytime window.
602        let clock = BarClock::new(Timeframe::Hour4).with_session(
603            TimezoneId::utc(),
604            Session::new([
605                SessionWindow::new(
606                    [Mon],
607                    NaiveTime::from_hms_opt(23, 0, 0).unwrap(),
608                    NaiveTime::from_hms_opt(23, 30, 0).unwrap(),
609                ),
610                SessionWindow::new(
611                    [Tue],
612                    NaiveTime::from_hms_opt(1, 0, 0).unwrap(),
613                    NaiveTime::MIN,
614                ),
615            ]),
616            false,
617        );
618        for (at, open, next) in [
619            (
620                "2024-07-08T22:00:00Z",
621                "2024-07-08T21:00:00Z",
622                "2024-07-08T23:00:00Z",
623            ),
624            (
625                "2024-07-08T23:15:00Z",
626                "2024-07-08T23:00:00Z",
627                "2024-07-09T00:00:00Z",
628            ),
629        ] {
630            assert_eq!(clock.bar_open(t(at)), t(open));
631            assert_eq!(clock.next_open(t(open)), Some(t(next)));
632            assert_eq!(
633                clock.bar_open(t(next) - TimeDelta::milliseconds(1)),
634                t(open)
635            );
636        }
637    }
638}