1use super::{Timeframe, TimezoneId};
4use chrono::{
5 DateTime, Datelike, LocalResult, NaiveDate, NaiveTime, Offset, TimeDelta, TimeZone, Utc,
6};
7
8const DAY_MS: i64 = 86_400_000;
9const FIRST_MONDAY_MS: i64 = 4 * DAY_MS;
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct BarClock {
15 timeframe: Option<Timeframe>,
17 timezone: Option<TimezoneId>,
18 session: Option<super::ExchangeSessionProvider>,
19}
20
21impl BarClock {
22 pub fn new(timeframe: Timeframe) -> Self {
24 Self {
25 timeframe: Some(timeframe),
26 timezone: Some(TimezoneId::utc()),
27 session: None,
28 }
29 }
30
31 pub fn with_timezone(mut self, timezone: TimezoneId) -> Self {
34 self.timezone = Some(timezone);
35 self
36 }
37
38 pub fn with_local_timezone(mut self) -> Self {
40 self.timezone = None;
41 self
42 }
43
44 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 #[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 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 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 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 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 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 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 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 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 pub(crate) fn timeframe(&self) -> Option<Timeframe> {
290 self.timeframe
291 }
292
293 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 pub fn has_session(&self) -> bool {
308 self.session.is_some()
309 }
310
311 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 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 #[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 ("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 (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 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 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}