rustrade_data/subscription/candle.rs
1use super::SubscriptionKind;
2use chrono::{DateTime, Duration, Utc};
3use rust_decimal::Decimal;
4use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
5use std::str::FromStr;
6use thiserror::Error;
7
8/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields [`Candle`]
9/// [`MarketEvent<T>`](crate::event::MarketEvent) events.
10///
11/// The [`interval`](Self::interval) is intrinsic to a candle subscription — it is
12/// the resolution being streamed, so there is no meaningful default (a phantom
13/// "1m" default is a silent-bug footgun); the field is always explicit.
14#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
15pub struct Candles {
16 /// The candle resolution to subscribe to. See [`CandleInterval`].
17 pub interval: CandleInterval,
18}
19
20impl SubscriptionKind for Candles {
21 type Event = Candle;
22
23 /// Returns the fixed kind tag `"candles"`, independent of [`interval`](Self::interval).
24 /// The tag identifies the subscription *kind* for routing and stays stable across
25 /// resolutions; it is **not** the interval. For the resolution string use
26 /// [`CandleInterval::as_str`] on the [`interval`](Self::interval) field — note that
27 /// [`Display`](std::fmt::Display) for `Candles` also yields only `"candles"`.
28 fn as_str(&self) -> &'static str {
29 "candles"
30 }
31}
32
33impl std::fmt::Display for Candles {
34 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35 f.write_str(self.as_str())
36 }
37}
38
39#[cfg(test)]
40#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
41mod tests {
42 use super::*;
43 use chrono::Duration;
44 use rust_decimal_macros::dec;
45
46 /// Parse an RFC3339 UTC instant in tests.
47 fn dt(s: &str) -> DateTime<Utc> {
48 DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
49 }
50
51 /// Build a test [`Candle`] closing at the given RFC3339 instant.
52 fn bar(
53 close_time: &str,
54 open: Decimal,
55 high: Decimal,
56 low: Decimal,
57 close: Decimal,
58 volume: Decimal,
59 trade_count: u64,
60 ) -> Candle {
61 Candle {
62 close_time: dt(close_time),
63 open,
64 high,
65 low,
66 close,
67 volume: Some(volume),
68 trade_count: Some(trade_count),
69 }
70 }
71
72 #[test]
73 fn fixed_step_adds_duration_exactly() {
74 let open = dt("2024-01-15T12:00:00Z");
75 assert_eq!(
76 close_time_from_open(open, IntervalStep::Fixed(Duration::minutes(1))),
77 Some(dt("2024-01-15T12:01:00Z"))
78 );
79 assert_eq!(
80 close_time_from_open(open, IntervalStep::Fixed(Duration::hours(1))),
81 Some(dt("2024-01-15T13:00:00Z"))
82 );
83 // Daily/weekly are exact fixed durations in UTC (no DST).
84 assert_eq!(
85 close_time_from_open(
86 dt("2024-01-15T00:00:00Z"),
87 IntervalStep::Fixed(Duration::days(1))
88 ),
89 Some(dt("2024-01-16T00:00:00Z"))
90 );
91 assert_eq!(
92 close_time_from_open(
93 dt("2024-01-15T00:00:00Z"),
94 IntervalStep::Fixed(Duration::weeks(1))
95 ),
96 Some(dt("2024-01-22T00:00:00Z"))
97 );
98 }
99
100 #[test]
101 fn fixed_daily_step_crosses_month_boundary() {
102 // A Jan 31 daily bar closes at Feb 1 00:00 UTC via Fixed(1 day).
103 assert_eq!(
104 close_time_from_open(
105 dt("2024-01-31T00:00:00Z"),
106 IntervalStep::Fixed(Duration::days(1))
107 ),
108 Some(dt("2024-02-01T00:00:00Z"))
109 );
110 }
111
112 #[test]
113 fn months_step_uses_calendar_arithmetic() {
114 // Jan -> Feb (not +30 days = Jan 31).
115 assert_eq!(
116 close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(1)),
117 Some(dt("2024-02-01T00:00:00Z"))
118 );
119 // Leap-year Feb -> Mar (Feb has 29 days in 2024).
120 assert_eq!(
121 close_time_from_open(dt("2024-02-01T00:00:00Z"), IntervalStep::Months(1)),
122 Some(dt("2024-03-01T00:00:00Z"))
123 );
124 // Quarter = 3 months.
125 assert_eq!(
126 close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(3)),
127 Some(dt("2024-04-01T00:00:00Z"))
128 );
129 // Year = 12 months.
130 assert_eq!(
131 close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(12)),
132 Some(dt("2025-01-01T00:00:00Z"))
133 );
134 }
135
136 #[test]
137 fn months_step_clamps_jan_31_anchor() {
138 // Monthly bar opens always land on the 1st from all known producers, so
139 // this clamping is unreachable in practice; the test pins chrono's
140 // documented behaviour for the variable-length-month edge case.
141 // chrono clamps to the last valid day: Jan 31 + 1 month -> Feb 29 (leap year).
142 assert_eq!(
143 close_time_from_open(dt("2024-01-31T00:00:00Z"), IntervalStep::Months(1)),
144 Some(dt("2024-02-29T00:00:00Z"))
145 );
146 }
147
148 #[test]
149 fn overflow_returns_none_not_panic() {
150 let max = DateTime::<Utc>::MAX_UTC;
151 assert_eq!(close_time_from_open(max, IntervalStep::Months(1)), None);
152 assert_eq!(
153 close_time_from_open(max, IntervalStep::Fixed(Duration::days(1))),
154 None
155 );
156 }
157
158 #[test]
159 fn open_time_from_close_is_inverse() {
160 // open = close − interval, for both Fixed and Months steps.
161 assert_eq!(
162 open_time_from_close(
163 dt("2024-01-15T13:00:00Z"),
164 IntervalStep::Fixed(Duration::hours(1))
165 ),
166 Some(dt("2024-01-15T12:00:00Z"))
167 );
168 // Feb 1 close of a January monthly bar → Jan 1 open.
169 assert_eq!(
170 open_time_from_close(dt("2024-02-01T00:00:00Z"), IntervalStep::Months(1)),
171 Some(dt("2024-01-01T00:00:00Z"))
172 );
173 // Round-trip identity for the inputs this library actually produces:
174 // monthly/quarterly closes always land on a calendar 1st, where chrono's
175 // month arithmetic round-trips exactly. (It is NOT a universal identity —
176 // `Months` day-clamping is asymmetric for non-1st anchors, e.g.
177 // Feb 29 −1mo → Jan 29, +1mo → Feb 29; see `months_step_clamps_jan_31_anchor`.)
178 let close = dt("2024-04-01T00:00:00Z");
179 let open = open_time_from_close(close, IntervalStep::Months(3)).unwrap();
180 assert_eq!(
181 close_time_from_open(open, IntervalStep::Months(3)),
182 Some(close)
183 );
184 }
185
186 #[test]
187 fn open_time_from_close_underflow_returns_none() {
188 let min = DateTime::<Utc>::MIN_UTC;
189 assert_eq!(open_time_from_close(min, IntervalStep::Months(1)), None);
190 assert_eq!(
191 open_time_from_close(min, IntervalStep::Fixed(Duration::days(1))),
192 None
193 );
194 }
195
196 #[test]
197 fn candle_interval_all_covers_every_variant_in_ascending_order() {
198 // `ALL`'s length is pinned to the variant count by both the
199 // `[CandleInterval; 19]` type and this assertion. Full variant *coverage*
200 // is not compile-enforced (Rust has no stable variant_count), so keep
201 // `ALL` in sync when adding a variant.
202 assert_eq!(CandleInterval::ALL.len(), 19);
203
204 // Verify the documented ascending-duration ordering directly via `to_step`.
205 // Comparing against the derived `Ord` would be tautological — that order is
206 // declaration order, identical to `ALL`'s. Mapping through durations instead
207 // actually fails if a variant is listed out of order.
208 fn approx_secs(interval: CandleInterval) -> i64 {
209 match interval.to_step() {
210 IntervalStep::Fixed(d) => d.num_seconds(),
211 // Only `Month1` is calendar-based; ~30d keeps it above `Week1` (7d).
212 IntervalStep::Months(n) => i64::from(n) * 30 * 24 * 60 * 60,
213 }
214 }
215 for pair in CandleInterval::ALL.windows(2) {
216 assert!(
217 approx_secs(pair[0]) < approx_secs(pair[1]),
218 "ALL must be in strictly ascending duration order: {:?} !< {:?}",
219 pair[0],
220 pair[1]
221 );
222 }
223 }
224
225 #[test]
226 fn candle_interval_display_equals_as_str_for_every_variant() {
227 for interval in CandleInterval::ALL {
228 assert_eq!(interval.to_string(), interval.as_str());
229 }
230 }
231
232 #[test]
233 fn candle_interval_as_str_matches_binance_exactly() {
234 // Case-sensitive: `1M` (month) is the only uppercase form.
235 assert_eq!(CandleInterval::Sec1.as_str(), "1s");
236 assert_eq!(CandleInterval::Min1.as_str(), "1m");
237 assert_eq!(CandleInterval::Hour6.as_str(), "6h");
238 assert_eq!(CandleInterval::Month1.as_str(), "1M");
239 }
240
241 #[test]
242 fn candle_interval_from_str_is_inverse_of_as_str() {
243 for interval in CandleInterval::ALL {
244 assert_eq!(interval.as_str().parse::<CandleInterval>(), Ok(interval));
245 }
246 }
247
248 #[test]
249 fn candle_interval_from_str_rejects_garbage() {
250 assert!("".parse::<CandleInterval>().is_err());
251 assert!("7m".parse::<CandleInterval>().is_err());
252 // Case-sensitive: `1m` (minute) must not parse as `1M` (month) or vice versa.
253 assert_eq!("1m".parse::<CandleInterval>(), Ok(CandleInterval::Min1));
254 assert_eq!("1M".parse::<CandleInterval>(), Ok(CandleInterval::Month1));
255 }
256
257 #[test]
258 fn candle_interval_serde_round_trips_every_variant() {
259 for interval in CandleInterval::ALL {
260 let json = serde_json::to_string(&interval).unwrap();
261 // Serialises as the bare `as_str()` string.
262 assert_eq!(json, format!("\"{}\"", interval.as_str()));
263 let back: CandleInterval = serde_json::from_str(&json).unwrap();
264 assert_eq!(back, interval);
265 }
266 }
267
268 #[test]
269 fn candles_kind_carries_interval_and_serde_round_trips() {
270 let kind = Candles {
271 interval: CandleInterval::Hour6,
272 };
273 let json = serde_json::to_string(&kind).unwrap();
274 assert_eq!(json, r#"{"interval":"6h"}"#);
275 let back: Candles = serde_json::from_str(&json).unwrap();
276 assert_eq!(back, kind);
277 // `SubscriptionKind::as_str` stays the kind tag, independent of interval.
278 assert_eq!(kind.as_str(), "candles");
279 assert_eq!(kind.to_string(), "candles");
280 }
281
282 #[test]
283 fn aggregate_rolls_three_1s_bars_into_one_3s_bar() {
284 let bars = [
285 bar(
286 "2024-01-15T12:00:01Z",
287 dec!(10),
288 dec!(12),
289 dec!(9),
290 dec!(11),
291 dec!(1),
292 5,
293 ),
294 bar(
295 "2024-01-15T12:00:02Z",
296 dec!(11),
297 dec!(15),
298 dec!(10),
299 dec!(14),
300 dec!(2),
301 3,
302 ),
303 bar(
304 "2024-01-15T12:00:03Z",
305 dec!(14),
306 dec!(16),
307 dec!(13),
308 dec!(15),
309 dec!(3),
310 2,
311 ),
312 ];
313 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
314 assert_eq!(
315 out,
316 vec![bar(
317 "2024-01-15T12:00:03Z",
318 dec!(10),
319 dec!(16),
320 dec!(9),
321 dec!(15),
322 dec!(6),
323 10
324 )]
325 );
326 }
327
328 #[test]
329 fn aggregate_splits_six_1s_bars_into_two_3s_bars() {
330 let bars = [
331 bar(
332 "2024-01-15T12:00:01Z",
333 dec!(10),
334 dec!(12),
335 dec!(9),
336 dec!(11),
337 dec!(1),
338 1,
339 ),
340 bar(
341 "2024-01-15T12:00:02Z",
342 dec!(11),
343 dec!(15),
344 dec!(10),
345 dec!(14),
346 dec!(2),
347 2,
348 ),
349 bar(
350 "2024-01-15T12:00:03Z",
351 dec!(14),
352 dec!(16),
353 dec!(13),
354 dec!(15),
355 dec!(3),
356 3,
357 ),
358 bar(
359 "2024-01-15T12:00:04Z",
360 dec!(15),
361 dec!(17),
362 dec!(14),
363 dec!(16),
364 dec!(4),
365 4,
366 ),
367 bar(
368 "2024-01-15T12:00:05Z",
369 dec!(16),
370 dec!(18),
371 dec!(15),
372 dec!(17),
373 dec!(5),
374 5,
375 ),
376 bar(
377 "2024-01-15T12:00:06Z",
378 dec!(17),
379 dec!(19),
380 dec!(12),
381 dec!(13),
382 dec!(6),
383 6,
384 ),
385 ];
386 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
387 assert_eq!(
388 out,
389 vec![
390 bar(
391 "2024-01-15T12:00:03Z",
392 dec!(10),
393 dec!(16),
394 dec!(9),
395 dec!(15),
396 dec!(6),
397 6
398 ),
399 bar(
400 "2024-01-15T12:00:06Z",
401 dec!(15),
402 dec!(19),
403 dec!(12),
404 dec!(13),
405 dec!(15),
406 15
407 ),
408 ]
409 );
410 }
411
412 #[test]
413 fn aggregate_buckets_snap_to_the_epoch_grid_not_the_first_sample() {
414 // Opens land at :01, :02, :03. First-sample anchoring would merge all
415 // three into one [:01, :04) bucket; the epoch grid splits them into
416 // [:00, :03) and [:03, :06).
417 let bars = [
418 bar(
419 "2024-01-15T12:00:02Z",
420 dec!(10),
421 dec!(12),
422 dec!(9),
423 dec!(11),
424 dec!(1),
425 1,
426 ),
427 bar(
428 "2024-01-15T12:00:03Z",
429 dec!(11),
430 dec!(15),
431 dec!(10),
432 dec!(14),
433 dec!(2),
434 2,
435 ),
436 bar(
437 "2024-01-15T12:00:04Z",
438 dec!(14),
439 dec!(16),
440 dec!(13),
441 dec!(15),
442 dec!(4),
443 3,
444 ),
445 ];
446 let target = Duration::seconds(3);
447 let out = aggregate_candles(&bars, Duration::seconds(1), target).unwrap();
448 assert_eq!(out.len(), 2);
449 assert_eq!(out[0].close_time, dt("2024-01-15T12:00:03Z"));
450 assert_eq!(out[0].volume, Some(dec!(3)));
451 assert_eq!(out[1].close_time, dt("2024-01-15T12:00:06Z"));
452 assert_eq!(out[1].volume, Some(dec!(4)));
453 // Every output boundary sits on the epoch-anchored target grid.
454 for candle in &out {
455 assert_eq!(
456 candle
457 .close_time
458 .timestamp_millis()
459 .rem_euclid(target.num_milliseconds()),
460 0
461 );
462 }
463 }
464
465 #[test]
466 fn aggregate_partial_bucket_does_not_scale_volume_to_bucket_width() {
467 let bars = [bar(
468 "2024-01-15T12:00:02Z",
469 dec!(10),
470 dec!(12),
471 dec!(9),
472 dec!(11),
473 dec!(5),
474 4,
475 )];
476 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
477 assert_eq!(
478 out,
479 vec![bar(
480 "2024-01-15T12:00:03Z",
481 dec!(10),
482 dec!(12),
483 dec!(9),
484 dec!(11),
485 dec!(5),
486 4
487 )]
488 );
489 }
490
491 #[test]
492 fn aggregate_omits_fully_empty_buckets() {
493 // Bars in the [:00, :03) and [:09, :12) buckets; [:03, :09) is a gap.
494 let bars = [
495 bar(
496 "2024-01-15T12:00:01Z",
497 dec!(10),
498 dec!(12),
499 dec!(9),
500 dec!(11),
501 dec!(1),
502 1,
503 ),
504 bar(
505 "2024-01-15T12:00:11Z",
506 dec!(20),
507 dec!(22),
508 dec!(19),
509 dec!(21),
510 dec!(2),
511 2,
512 ),
513 ];
514 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
515 // No synthetic bars for the empty buckets: gap policy is the caller's.
516 assert_eq!(
517 out,
518 vec![
519 bar(
520 "2024-01-15T12:00:03Z",
521 dec!(10),
522 dec!(12),
523 dec!(9),
524 dec!(11),
525 dec!(1),
526 1
527 ),
528 bar(
529 "2024-01-15T12:00:12Z",
530 dec!(20),
531 dec!(22),
532 dec!(19),
533 dec!(21),
534 dec!(2),
535 2
536 ),
537 ]
538 );
539 }
540
541 #[test]
542 fn aggregate_rejects_invalid_interval_arguments() {
543 let base = Duration::seconds(2);
544 assert_eq!(
545 aggregate_candles(&[], base, Duration::seconds(5)).unwrap_err(),
546 AggregateCandlesError::TargetNotMultipleOfBase {
547 base,
548 target: Duration::seconds(5)
549 }
550 );
551 assert_eq!(
552 aggregate_candles(&[], base, Duration::seconds(1)).unwrap_err(),
553 AggregateCandlesError::TargetSmallerThanBase {
554 base,
555 target: Duration::seconds(1)
556 }
557 );
558 assert_eq!(
559 aggregate_candles(&[], Duration::zero(), Duration::seconds(3)).unwrap_err(),
560 AggregateCandlesError::NonPositiveInterval {
561 base: Duration::zero(),
562 target: Duration::seconds(3)
563 }
564 );
565 assert_eq!(
566 aggregate_candles(&[], base, Duration::seconds(-3)).unwrap_err(),
567 AggregateCandlesError::NonPositiveInterval {
568 base,
569 target: Duration::seconds(-3)
570 }
571 );
572 }
573
574 #[test]
575 fn aggregate_rejects_sub_millisecond_intervals() {
576 // 1.5ms floors to 1ms at the bucketing granularity, which would
577 // silently mis-bucket — rejected at entry instead.
578 let base = Duration::nanoseconds(1_500_000);
579 let target = Duration::milliseconds(3);
580 assert_eq!(
581 aggregate_candles(&[], base, target).unwrap_err(),
582 AggregateCandlesError::SubMillisecondInterval { base, target }
583 );
584 let base = Duration::milliseconds(1);
585 let target = Duration::nanoseconds(2_500_000);
586 assert_eq!(
587 aggregate_candles(&[], base, target).unwrap_err(),
588 AggregateCandlesError::SubMillisecondInterval { base, target }
589 );
590 }
591
592 #[test]
593 fn aggregate_of_empty_input_is_empty() {
594 let out = aggregate_candles(&[], Duration::seconds(1), Duration::seconds(3)).unwrap();
595 assert!(out.is_empty());
596 }
597
598 #[test]
599 fn aggregate_close_time_round_trips_through_the_shared_boundary_helper() {
600 let bars = [bar(
601 "2024-01-15T12:00:01Z",
602 dec!(10),
603 dec!(12),
604 dec!(9),
605 dec!(11),
606 dec!(1),
607 1,
608 )];
609 let target = Duration::seconds(3);
610 let out = aggregate_candles(&bars, Duration::seconds(1), target).unwrap();
611 // close_time == bucket_open + target, via the single-sourced helper.
612 assert_eq!(
613 out[0].close_time,
614 close_time_from_open(dt("2024-01-15T12:00:00Z"), IntervalStep::Fixed(target)).unwrap()
615 );
616 }
617
618 #[test]
619 fn aggregate_surfaces_bucket_close_overflow_as_an_error() {
620 let target = Duration::seconds(3);
621 // The final epoch-grid bucket below the maximum representable instant:
622 // a 1ms sub-bar at its very start is itself representable, but the
623 // bucket's exclusive close boundary is not.
624 let bucket_open_ms = DateTime::<Utc>::MAX_UTC
625 .timestamp_millis()
626 .div_euclid(target.num_milliseconds())
627 * target.num_milliseconds();
628 let close_time = DateTime::from_timestamp_millis(bucket_open_ms + 1).unwrap();
629 let bars = [Candle {
630 close_time,
631 open: dec!(1),
632 high: dec!(1),
633 low: dec!(1),
634 close: dec!(1),
635 volume: Some(dec!(1)),
636 trade_count: Some(1),
637 }];
638 assert_eq!(
639 aggregate_candles(&bars, Duration::milliseconds(1), target).unwrap_err(),
640 AggregateCandlesError::TimestampOutOfRange { index: 0 }
641 );
642 }
643
644 #[test]
645 fn aggregate_sixty_1s_bars_matches_the_fields_of_a_native_1m_bar() {
646 let session_open = dt("2024-01-15T12:00:00Z");
647 let bars = (0..60i64)
648 .map(|i| Candle {
649 close_time: session_open + Duration::seconds(i + 1),
650 open: Decimal::from(100 + i),
651 high: Decimal::from(105 + i),
652 low: Decimal::from(95 + i),
653 close: Decimal::from(101 + i),
654 volume: Some(Decimal::from(2)),
655 trade_count: Some(3),
656 })
657 .collect::<Vec<_>>();
658 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::minutes(1)).unwrap();
659 // Exactly the fields a native 1m candle over the same trades carries.
660 assert_eq!(
661 out,
662 vec![Candle {
663 close_time: dt("2024-01-15T12:01:00Z"),
664 open: Decimal::from(100),
665 high: Decimal::from(164),
666 low: Decimal::from(95),
667 close: Decimal::from(160),
668 volume: Some(Decimal::from(120)),
669 trade_count: Some(180),
670 }]
671 );
672 }
673
674 #[test]
675 fn aggregate_any_none_constituent_poisons_the_bucket() {
676 // Any sub-bar with unknown volume (or trade count) makes the whole
677 // aggregated bucket unknown — never a silent under-count of the known
678 // parts. This is the load-bearing invariant behind `Candle`'s optional
679 // volume: an unknown component makes the sum unknown.
680 let cell = |close: &str, v: Option<Decimal>, n: Option<u64>| Candle {
681 close_time: dt(close),
682 open: dec!(1),
683 high: dec!(1),
684 low: dec!(1),
685 close: dec!(1),
686 volume: v,
687 trade_count: n,
688 };
689 let bars = [
690 cell("2024-01-15T12:00:01Z", Some(dec!(1)), Some(1)),
691 cell("2024-01-15T12:00:02Z", None, Some(2)), // unknown volume
692 cell("2024-01-15T12:00:03Z", Some(dec!(3)), None), // unknown trade count
693 ];
694 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
695 assert_eq!(out.len(), 1);
696 assert_eq!(
697 out[0].volume, None,
698 "one None-volume constituent poisons the summed volume"
699 );
700 assert_eq!(
701 out[0].trade_count, None,
702 "one None-trade_count constituent poisons the summed trade_count"
703 );
704 }
705
706 #[test]
707 fn aggregate_all_known_constituents_sum_normally() {
708 // Control for the poisoning test: when every constituent is known the
709 // bucket carries the plain totals.
710 let bars = [
711 bar(
712 "2024-01-15T12:00:01Z",
713 dec!(1),
714 dec!(1),
715 dec!(1),
716 dec!(1),
717 dec!(2),
718 5,
719 ),
720 bar(
721 "2024-01-15T12:00:02Z",
722 dec!(1),
723 dec!(1),
724 dec!(1),
725 dec!(1),
726 dec!(3),
727 7,
728 ),
729 ];
730 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
731 assert_eq!(out.len(), 1);
732 assert_eq!(out[0].volume, Some(dec!(5)));
733 assert_eq!(out[0].trade_count, Some(12));
734 }
735
736 #[test]
737 fn aggregate_gap_filled_input_carries_fills_without_double_counting() {
738 // Pre-gap-filled contiguous 1s series: flat v=0 fill bars around one
739 // real bar. Fills carry into open/low; zero volumes cannot
740 // double-count, so fill-before-aggregate equals fill-after.
741 let bars = [
742 bar(
743 "2024-01-15T12:00:01Z",
744 dec!(90),
745 dec!(90),
746 dec!(90),
747 dec!(90),
748 dec!(0),
749 0,
750 ),
751 bar(
752 "2024-01-15T12:00:02Z",
753 dec!(100),
754 dec!(101),
755 dec!(99),
756 dec!(100.5),
757 dec!(10),
758 7,
759 ),
760 bar(
761 "2024-01-15T12:00:03Z",
762 dec!(100.5),
763 dec!(100.5),
764 dec!(100.5),
765 dec!(100.5),
766 dec!(0),
767 0,
768 ),
769 ];
770 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
771 assert_eq!(
772 out,
773 vec![bar(
774 "2024-01-15T12:00:03Z",
775 dec!(90),
776 dec!(101),
777 dec!(90),
778 dec!(100.5),
779 dec!(10),
780 7
781 )]
782 );
783 }
784
785 #[test]
786 fn aggregate_bucket_of_only_zero_volume_bars_emits_one_flat_bar() {
787 // "Non-empty" means has-candles, not has-volume.
788 let bars = [
789 bar(
790 "2024-01-15T12:00:01Z",
791 dec!(50),
792 dec!(50),
793 dec!(50),
794 dec!(50),
795 dec!(0),
796 0,
797 ),
798 bar(
799 "2024-01-15T12:00:02Z",
800 dec!(50),
801 dec!(50),
802 dec!(50),
803 dec!(50),
804 dec!(0),
805 0,
806 ),
807 bar(
808 "2024-01-15T12:00:03Z",
809 dec!(50),
810 dec!(50),
811 dec!(50),
812 dec!(50),
813 dec!(0),
814 0,
815 ),
816 ];
817 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
818 assert_eq!(
819 out,
820 vec![bar(
821 "2024-01-15T12:00:03Z",
822 dec!(50),
823 dec!(50),
824 dec!(50),
825 dec!(50),
826 dec!(0),
827 0
828 )]
829 );
830 }
831
832 #[test]
833 fn aggregate_rejects_unsorted_and_duplicate_input_observably() {
834 let first = bar(
835 "2024-01-15T12:00:02Z",
836 dec!(10),
837 dec!(12),
838 dec!(9),
839 dec!(11),
840 dec!(1),
841 1,
842 );
843 let second = bar(
844 "2024-01-15T12:00:01Z",
845 dec!(11),
846 dec!(15),
847 dec!(10),
848 dec!(14),
849 dec!(2),
850 2,
851 );
852 assert_eq!(
853 aggregate_candles(&[first, second], Duration::seconds(1), Duration::seconds(3))
854 .unwrap_err(),
855 AggregateCandlesError::NonMonotonicInput { index: 1 }
856 );
857 // Strict ordering also rejects duplicate close_times (double-fetch /
858 // overlapping-page data bugs) rather than double-counting their volume.
859 assert_eq!(
860 aggregate_candles(&[first, first], Duration::seconds(1), Duration::seconds(3))
861 .unwrap_err(),
862 AggregateCandlesError::NonMonotonicInput { index: 1 }
863 );
864 }
865
866 #[test]
867 fn aggregate_surfaces_sub_bar_open_underflow_as_an_error() {
868 // close_time at the minimum representable instant: the sub-bar's open
869 // (close − base) underflows and must surface, not panic.
870 let bars = [Candle {
871 close_time: DateTime::<Utc>::MIN_UTC,
872 open: dec!(1),
873 high: dec!(1),
874 low: dec!(1),
875 close: dec!(1),
876 volume: Some(dec!(1)),
877 trade_count: Some(1),
878 }];
879 assert_eq!(
880 aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap_err(),
881 AggregateCandlesError::TimestampOutOfRange { index: 0 }
882 );
883 }
884
885 #[test]
886 fn aggregate_surfaces_bucket_open_underflow_from_grid_flooring_as_an_error() {
887 // The sub-bar's own open is exactly the minimum representable instant,
888 // so the per-candle underflow check passes — but flooring that open
889 // onto a coarse 3-day grid lands below it, a distinct underflow that
890 // only the bucket-level check catches.
891 let base = Duration::milliseconds(1);
892 let bars = [Candle {
893 close_time: DateTime::<Utc>::MIN_UTC + base,
894 open: dec!(1),
895 high: dec!(1),
896 low: dec!(1),
897 close: dec!(1),
898 volume: Some(dec!(1)),
899 trade_count: Some(1),
900 }];
901 assert_eq!(
902 aggregate_candles(&bars, base, Duration::days(3)).unwrap_err(),
903 AggregateCandlesError::TimestampOutOfRange { index: 0 }
904 );
905 }
906
907 #[test]
908 fn aggregate_floors_pre_epoch_opens_toward_negative_infinity() {
909 // Sub-bar open is 1s *before* the epoch. Euclidean flooring snaps it to
910 // -3s, so the bucket closes exactly on the epoch. Plain `/` truncates
911 // toward zero, snapping to 0s and mis-bucketing the bar one bucket late.
912 let bars = [bar(
913 "1970-01-01T00:00:00Z",
914 dec!(10),
915 dec!(12),
916 dec!(9),
917 dec!(11),
918 dec!(1),
919 1,
920 )];
921 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
922 assert_eq!(
923 out,
924 vec![bar(
925 "1970-01-01T00:00:00Z",
926 dec!(10),
927 dec!(12),
928 dec!(9),
929 dec!(11),
930 dec!(1),
931 1
932 )]
933 );
934 }
935
936 #[test]
937 fn aggregate_with_target_equal_to_base_is_an_identity_regrid() {
938 // k = 1 passes validation deliberately: on-grid input passes through.
939 let bars = [
940 bar(
941 "2024-01-15T12:00:01Z",
942 dec!(10),
943 dec!(12),
944 dec!(9),
945 dec!(11),
946 dec!(1),
947 1,
948 ),
949 bar(
950 "2024-01-15T12:00:02Z",
951 dec!(11),
952 dec!(15),
953 dec!(10),
954 dec!(14),
955 dec!(2),
956 2,
957 ),
958 ];
959 let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(1)).unwrap();
960 assert_eq!(out, bars.to_vec());
961 }
962
963 /// Pins the serde contract documented on [`Candle::volume`].
964 ///
965 /// The absent-key case is the one worth a test: making the field optional turned a `missing
966 /// field` rejection into a silent `None`, and nothing else in the codebase would notice if a
967 /// `#[serde(default)]`-style change or a rename reintroduced the old behaviour.
968 #[test]
969 fn an_absent_volume_or_trade_count_deserialises_as_unknown_and_a_zero_stays_a_zero() {
970 let without = serde_json::json!({
971 "close_time": "2024-01-15T12:00:00Z",
972 "open": "10", "high": "12", "low": "9", "close": "11",
973 });
974 let candle: Candle = serde_json::from_value(without).unwrap();
975 assert_eq!(candle.volume, None);
976 assert_eq!(candle.trade_count, None);
977
978 // A record written before the migration: the fabricated zero survives verbatim, so this
979 // type cannot recover the distinction it now expresses.
980 let pre_migration = serde_json::json!({
981 "close_time": "2024-01-15T12:00:00Z",
982 "open": "10", "high": "12", "low": "9", "close": "11",
983 "volume": "0", "trade_count": 0,
984 });
985 let candle: Candle = serde_json::from_value(pre_migration).unwrap();
986 assert_eq!(candle.volume, Some(Decimal::ZERO));
987 assert_eq!(candle.trade_count, Some(0));
988
989 // `None` round-trips as an explicit null rather than degrading to the absent-key case.
990 let unknown = Candle {
991 close_time: dt("2024-01-15T12:00:00Z"),
992 open: dec!(10),
993 high: dec!(12),
994 low: dec!(9),
995 close: dec!(11),
996 volume: None,
997 trade_count: None,
998 };
999 let encoded = serde_json::to_value(unknown).unwrap();
1000 assert_eq!(encoded["volume"], serde_json::Value::Null);
1001 assert_eq!(encoded["trade_count"], serde_json::Value::Null);
1002 assert_eq!(serde_json::from_value::<Candle>(encoded).unwrap(), unknown);
1003 }
1004}
1005
1006/// Normalised Barter OHLCV [`Candle`] model.
1007///
1008/// # `close_time` contract
1009///
1010/// `close_time` is the **exclusive end-of-period boundary** of the candle:
1011///
1012/// ```text
1013/// close_time == open_time + interval
1014/// ```
1015///
1016/// A candle aggregates the trades that fall in the **half-open interval**
1017/// `[close_time − interval, close_time)` — i.e. trades with
1018/// `open_time ≤ ts < close_time`. A trade landing exactly on `close_time`
1019/// belongs to the **next** candle, so `close_time` equals the next candle's
1020/// open instant.
1021///
1022/// Two distinct caveats apply to the boundary — do not conflate them:
1023///
1024/// - **Not session-aligned** (daily/weekly/monthly): the boundary is the UTC
1025/// period grid (`day → next 00:00 UTC`, etc.), **not** an exchange session
1026/// close. The library has no session calendar.
1027/// - **Variable-length calendar arithmetic** (month/quarter/year only): these
1028/// are nominal boundaries computed with calendar months (chrono [`Months`]),
1029/// not fixed [`Duration`]s. Daily and weekly are exact fixed durations in UTC
1030/// (no DST), exact to the millisecond. The `open_time + N months` equality
1031/// holds only when `open_time` is calendar-grid-aligned (e.g. a `1M` candle
1032/// opens on the 1st at 00:00 UTC); a non-aligned open day-clamps
1033/// (Jan 31 + 1 month → Feb 28/29). Venue-supplied open times are always
1034/// aligned, so clamping never arises in practice, but it is part of the
1035/// contract for callers computing boundaries from arbitrary instants.
1036///
1037/// `Candle` deliberately carries **neither `open_time` nor `interval`** — recover
1038/// them from the originating fetch request / subscription resolution
1039/// (`open_time ≡ close_time − interval`). Range-computing producers derive
1040/// `close_time` through [`close_time_from_open`] so the boundary is defined in
1041/// exactly one place (the Massive WS path uses the venue-supplied boundary
1042/// directly — see [`close_time_from_open`] for the full producer list).
1043///
1044/// # Using a `Candle` with the engine
1045///
1046/// When wrapping a `Candle` into a [`MarketEvent`](crate::event::MarketEvent) for
1047/// a consuming engine (live or backtest), set
1048/// [`time_exchange`](crate::event::MarketEvent::time_exchange) to this
1049/// `close_time` — it is the period-END instant, the only choice that avoids
1050/// lookahead (see that field's contract). The library's own candle producers
1051/// already do this.
1052///
1053/// [`Months`]: chrono::Months
1054/// [`Duration`]: chrono::Duration
1055#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Hash, Debug, Deserialize, Serialize)]
1056pub struct Candle {
1057 pub close_time: DateTime<Utc>,
1058 pub open: Decimal,
1059 pub high: Decimal,
1060 pub low: Decimal,
1061 pub close: Decimal,
1062 /// Consolidated trade volume over the candle period, or `None` when the
1063 /// producer carries no volume for this instrument class.
1064 ///
1065 /// `None` is a first-class "volume is unknown" fact, **not** zero: some feeds
1066 /// (e.g. spot FX candles) publish no consolidated volume at all. Encoding
1067 /// that absence as `Some(0.0)` would be a silent lie — a volume-derived
1068 /// feature would read a real, quiet zero rather than an explicit unknown.
1069 /// [`aggregate_candles`] propagates the absence: any `None` constituent makes
1070 /// the whole aggregated bucket `None`.
1071 ///
1072 /// # Serde contract
1073 ///
1074 /// Plain `Option` semantics, with two consequences worth stating because they
1075 /// changed when this field became optional:
1076 ///
1077 /// - An **absent** `volume` key now deserialises to `None`. It was previously a
1078 /// hard `missing field` error, so a truncated or hand-written record that used
1079 /// to be rejected is now accepted as "volume unknown". Reject it explicitly if
1080 /// a producer of yours must always report one.
1081 /// - A record persisted **before** the migration carries the fabricated
1082 /// `"volume": 0` verbatim, and reads back as `Some(0)` — indistinguishable
1083 /// from a genuine zero-volume bar. This type cannot recover the distinction;
1084 /// re-fetch, or track the affected range out-of-band.
1085 ///
1086 /// `None` serialises as `"volume": null` (the key is written, not skipped), so a
1087 /// round trip through JSON preserves absence rather than degrading it into the
1088 /// missing-key case above.
1089 pub volume: Option<Decimal>,
1090 /// Number of trades in the candle period, or `None` when the producer does
1091 /// not report a trade count.
1092 ///
1093 /// Same contract as [`volume`](Self::volume) throughout, including serde:
1094 /// `None` means "unknown", never zero; any `None` constituent makes an
1095 /// aggregated bucket `None`; an absent key deserialises to `None`; and a
1096 /// pre-migration `"trade_count": 0` still reads back as `Some(0)`.
1097 pub trade_count: Option<u64>,
1098}
1099
1100/// One step from a candle's open instant to its exclusive close boundary.
1101///
1102/// Keyed on a primitive step type (not on any per-exchange interval enum) so
1103/// every producer — regardless of how it names its native intervals — maps to
1104/// the same two cases and routes through [`close_time_from_open`]. This is the
1105/// mechanism that makes the [`Candle::close_time`] contract *enforced by
1106/// construction* rather than merely documented.
1107#[derive(Copy, Clone, Eq, PartialEq, Debug)]
1108pub enum IntervalStep {
1109 /// A fixed-length step (seconds through weeks — exact in UTC, no DST).
1110 Fixed(chrono::Duration),
1111 /// A variable-length calendar step in whole months. Covers calendar
1112 /// `month` (1), `quarter` (3) and `year` (12) — leap-year-correct via
1113 /// chrono's [`checked_add_months`](DateTime::checked_add_months).
1114 Months(u32),
1115}
1116
1117/// Compute a candle's exclusive `close_time` boundary from its `open` instant.
1118///
1119/// This is the single shared boundary helper that every range-computing
1120/// [`Candle`] producer routes through (Massive REST, Hyperliquid, IBKR), so the
1121/// `close_time == open + interval` contract is computed in exactly one place. The
1122/// Massive WS path is the lone exception: it trusts the venue-supplied boundary
1123/// directly (see `WsAggregateMsg::into_candle` for the rationale).
1124///
1125/// - [`IntervalStep::Fixed`] adds a [`chrono::Duration`].
1126/// - [`IntervalStep::Months`] uses calendar-correct month arithmetic
1127/// ([`checked_add_months`](DateTime::checked_add_months)), so a Jan monthly
1128/// bar yields `Feb 1 00:00 UTC` and a leap-year Feb monthly bar yields
1129/// `Mar 1 00:00 UTC`.
1130///
1131/// # Returns
1132///
1133/// `None` on overflow — when the computed boundary falls outside the
1134/// representable [`DateTime<Utc>`] range. Callers **must** surface this as their
1135/// producer error type (an observable failure), **never** a silent fallback to a
1136/// plausible-but-wrong timestamp such as `UNIX_EPOCH`.
1137#[must_use]
1138pub fn close_time_from_open(open: DateTime<Utc>, step: IntervalStep) -> Option<DateTime<Utc>> {
1139 match step {
1140 IntervalStep::Fixed(duration) => open.checked_add_signed(duration),
1141 IntervalStep::Months(n) => open.checked_add_months(chrono::Months::new(n)),
1142 }
1143}
1144
1145/// Inverse of [`close_time_from_open`]: recover a candle's `open` instant from its
1146/// exclusive `close_time` boundary (`open == close − interval`).
1147///
1148/// Used by range-bounded historical fetches to widen the venue request window:
1149/// the candle whose `close_time == start` has `open == start − interval`, so a
1150/// fetch that wants `close_time ∈ [start, end]` must ask the venue for opens down
1151/// to `start − interval` (then trim the result by `close_time`). See
1152/// [`Candle::close_time`].
1153///
1154/// # Returns
1155///
1156/// `None` on underflow (the computed open falls below the representable
1157/// [`DateTime<Utc>`] range).
1158///
1159/// For the range-widening use-case this `None` is **not** an error: it means the
1160/// candle whose `close_time == start` would have an unrepresentable open
1161/// (`start − interval` below [`DateTime<Utc>`] minimum) and therefore cannot
1162/// exist. Callers should fall back to the original lower bound — the un-widened
1163/// fetch already yields the complete, correct result set, so this is the right
1164/// outcome rather than a silent failure. (Contrast [`close_time_from_open`],
1165/// whose `None` *does* signal data loss for a real candle and must be surfaced
1166/// as an error.)
1167#[must_use]
1168pub fn open_time_from_close(close: DateTime<Utc>, step: IntervalStep) -> Option<DateTime<Utc>> {
1169 match step {
1170 IntervalStep::Fixed(duration) => close.checked_sub_signed(duration),
1171 IntervalStep::Months(n) => close.checked_sub_months(chrono::Months::new(n)),
1172 }
1173}
1174
1175/// Error returned by [`aggregate_candles`] for invalid arguments or input.
1176///
1177/// Every variant is an observable failure: the aggregation never silently
1178/// repairs, reorders or drops input.
1179#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
1180#[non_exhaustive]
1181pub enum AggregateCandlesError {
1182 /// `base_interval` or `target` is zero or negative.
1183 #[error("candle intervals must be positive (base: {base}, target: {target})")]
1184 NonPositiveInterval {
1185 /// The `base_interval` argument as supplied.
1186 base: Duration,
1187 /// The `target` argument as supplied.
1188 target: Duration,
1189 },
1190 /// `base_interval` or `target` has a sub-millisecond component. Bucketing
1191 /// operates at millisecond granularity (venue candle timestamps are
1192 /// millisecond-resolution), so a sub-millisecond interval would silently
1193 /// mis-bucket — it is rejected instead.
1194 #[error("candle intervals must be whole milliseconds (base: {base}, target: {target})")]
1195 SubMillisecondInterval {
1196 /// The `base_interval` argument as supplied.
1197 base: Duration,
1198 /// The `target` argument as supplied.
1199 target: Duration,
1200 },
1201 /// `target` is shorter than `base_interval` — aggregation only coarsens.
1202 #[error(
1203 "target interval must not be smaller than the base interval (base: {base}, target: {target})"
1204 )]
1205 TargetSmallerThanBase {
1206 /// The `base_interval` argument as supplied.
1207 base: Duration,
1208 /// The `target` argument as supplied.
1209 target: Duration,
1210 },
1211 /// `target` is not an integer multiple of `base_interval`, so base candles
1212 /// cannot tile target buckets exactly.
1213 #[error(
1214 "target interval must be an integer multiple of the base interval (base: {base}, target: {target})"
1215 )]
1216 TargetNotMultipleOfBase {
1217 /// The `base_interval` argument as supplied.
1218 base: Duration,
1219 /// The `target` argument as supplied.
1220 target: Duration,
1221 },
1222 /// The input candle at `index` is not strictly later (by `close_time`) than
1223 /// its predecessor. Strictness also rejects duplicate timestamps: no two
1224 /// distinct fixed-interval candles can legitimately share a `close_time`,
1225 /// so a duplicate is a data bug (e.g. an overlapping-page double fetch)
1226 /// whose volume must not be double-counted.
1227 #[error("input candles must be strictly ascending by close_time (violation at index {index})")]
1228 NonMonotonicInput {
1229 /// Index into the input slice of the offending candle.
1230 index: usize,
1231 },
1232 /// A boundary computed for the candle at `index` falls outside the
1233 /// representable [`DateTime<Utc>`] range. Three computations can fail this
1234 /// way: the sub-bar `open` underflowed (input near the minimum instant);
1235 /// the bucket's epoch-grid-floored open underflowed (flooring a
1236 /// *representable* open onto a coarse `target` grid can still land below
1237 /// the minimum instant); or the bucket `close_time` overflowed (input near
1238 /// the maximum instant).
1239 #[error(
1240 "candle at index {index} yields a boundary outside the representable DateTime<Utc> range"
1241 )]
1242 TimestampOutOfRange {
1243 /// Index into the input slice attributing the failure: the offending
1244 /// candle itself for a sub-bar `open` underflow, or the affected
1245 /// bucket's first sub-bar for a bucket-level boundary failure (whose
1246 /// own sub-bar boundary need not be invalid).
1247 index: usize,
1248 },
1249}
1250
1251/// Aggregate fixed-interval OHLCV [`Candle`]s into a coarser fixed interval.
1252///
1253/// A pure, venue-agnostic batch primitive: `candles` at resolution
1254/// `base_interval` are bucketed onto the epoch-anchored `target` grid, and one
1255/// aggregated [`Candle`] is emitted per **non-empty** bucket, ascending by
1256/// `close_time`. All price/volume arithmetic is [`Decimal`]-exact — no
1257/// floating point.
1258///
1259/// # Aggregation rule
1260///
1261/// Each output candle carries, over its bucket's sub-bars:
1262///
1263/// - `open`: the earliest sub-bar's `open`
1264/// - `close`: the latest sub-bar's `close`
1265/// - `high` / `low`: the maximum / minimum across the bucket
1266/// - `volume` / `trade_count`: sums across the bucket
1267/// - `close_time`: the bucket's exclusive close boundary
1268/// (`bucket_open + target`), computed via [`close_time_from_open`] so the
1269/// [`Candle::close_time`] contract stays single-sourced
1270///
1271/// A **partial** bucket (fewer than `target / base_interval` sub-bars present)
1272/// aggregates only the sub-bars actually present — `volume` is never scaled up
1273/// to the bucket width.
1274///
1275/// # Bucketing: epoch anchoring
1276///
1277/// Each sub-bar's open (recovered as `close_time − base_interval` via
1278/// [`open_time_from_close`]) is floored onto the grid of integer multiples of
1279/// `target` since the Unix epoch. This is the natural anchoring for every
1280/// target that divides a day (seconds through hours). **Caveat**: the Unix
1281/// epoch was a *Thursday*, so for multi-day targets — e.g. 3-day or weekly
1282/// buckets — the epoch grid diverges from venue-native anchoring (venues serve
1283/// Monday-anchored weekly candles). Callers wanting venue-native weekly bars
1284/// should fetch them from the venue rather than aggregate.
1285///
1286/// # Caller obligations
1287///
1288/// - **Strictly ascending**: input must be strictly ascending by `close_time`.
1289/// Violations (including duplicate timestamps) surface as
1290/// [`AggregateCandlesError::NonMonotonicInput`] — input is never silently
1291/// reordered or deduplicated.
1292/// - **Uniform resolution**: every input candle must span exactly
1293/// `base_interval` (its open is `close_time − base_interval`). This cannot
1294/// be validated from a [`Candle`] alone (candles carry no interval field)
1295/// and is trusted.
1296/// - **Grid alignment is _not_ validated**: sub-bar opens are floored onto the
1297/// epoch grid wherever they fall, so legitimately non-epoch-anchored input
1298/// (e.g. venue-native Monday-anchored weekly bars) is accepted — but its
1299/// buckets are epoch-anchored regardless (see above).
1300/// - **Millisecond timestamps**: bucketing operates at millisecond
1301/// granularity (venue candle timestamps are millisecond-resolution). A
1302/// `close_time` with a sub-millisecond component has that component ignored
1303/// during bucketing. Sub-millisecond *intervals* are rejected as
1304/// [`AggregateCandlesError::SubMillisecondInterval`].
1305///
1306/// # Gap policy: none (deliberately)
1307///
1308/// A fully-empty bucket emits **no** output candle — synthesising flat bars
1309/// (forward-fill) is consumer policy, not a library concern. The primitive is
1310/// neutral about which side of the call that policy runs on:
1311///
1312/// - feed raw, sparse candles and gap-fill the *output*, or
1313/// - feed a pre-gap-filled contiguous series — zero-volume fill bars aggregate
1314/// consistently (they carry into `open`/`high`/`low`/`close`; volume sums
1315/// are unchanged, so fills cannot double-count).
1316///
1317/// "Non-empty" means *has input candles*, not *has volume*: a bucket
1318/// containing only zero-volume fill bars emits one flat zero-volume candle.
1319///
1320/// # Trailing bucket
1321///
1322/// Batch semantics: the final bucket is emitted even if the input merely
1323/// *ends* mid-bucket — indistinguishable from a trailing gap. Live/streaming
1324/// callers must withhold input until a bucket completes (or drop the tail)
1325/// themselves — and should feed bounded windows, not re-aggregate a growing
1326/// history buffer per event: every call re-validates and re-scans its entire
1327/// input.
1328///
1329/// # Limitations
1330///
1331/// Fixed durations only: calendar-month aggregation (variable-length buckets,
1332/// [`IntervalStep::Months`]) is out of scope. `target == base_interval` is
1333/// accepted and acts as an identity/re-grid pass.
1334///
1335/// # Panics
1336///
1337/// Panics if a bucket's accumulated `volume` overflows [`Decimal`] or its
1338/// accumulated `trade_count` overflows `u64`. Both require magnitudes no
1339/// venue feed produces (~7.9 × 10²⁸ summed volume, ~1.8 × 10¹⁹ summed
1340/// trades), so either indicates corrupt input; the panic keeps that failure
1341/// loud on every build profile rather than wrapping silently.
1342///
1343/// # Errors
1344///
1345/// See [`AggregateCandlesError`]. Boundary computations that leave the
1346/// representable [`DateTime<Utc>`] range — sub-bar open underflow near the
1347/// minimum instant, epoch-grid flooring pushing a bucket's open below the
1348/// minimum instant, or bucket `close_time` overflow near the maximum — surface
1349/// as [`AggregateCandlesError::TimestampOutOfRange`] — never a silent
1350/// plausible-but-wrong timestamp.
1351pub fn aggregate_candles(
1352 candles: &[Candle],
1353 base_interval: Duration,
1354 target: Duration,
1355) -> Result<Vec<Candle>, AggregateCandlesError> {
1356 if base_interval <= Duration::zero() || target <= Duration::zero() {
1357 return Err(AggregateCandlesError::NonPositiveInterval {
1358 base: base_interval,
1359 target,
1360 });
1361 }
1362 // Bucketing operates at millisecond granularity (venue candle timestamps
1363 // are ms); a sub-ms interval would floor and silently mis-bucket.
1364 if Duration::milliseconds(base_interval.num_milliseconds()) != base_interval
1365 || Duration::milliseconds(target.num_milliseconds()) != target
1366 {
1367 return Err(AggregateCandlesError::SubMillisecondInterval {
1368 base: base_interval,
1369 target,
1370 });
1371 }
1372 if target < base_interval {
1373 return Err(AggregateCandlesError::TargetSmallerThanBase {
1374 base: base_interval,
1375 target,
1376 });
1377 }
1378 let target_ms = target.num_milliseconds();
1379 if target_ms % base_interval.num_milliseconds() != 0 {
1380 return Err(AggregateCandlesError::TargetNotMultipleOfBase {
1381 base: base_interval,
1382 target,
1383 });
1384 }
1385
1386 /// An in-progress epoch-grid bucket accumulator.
1387 struct Bucket {
1388 /// The bucket's open instant in epoch milliseconds (on the `target` grid).
1389 open_ms: i64,
1390 /// Index of the bucket's first sub-bar, for error attribution.
1391 first_index: usize,
1392 open: Decimal,
1393 high: Decimal,
1394 low: Decimal,
1395 close: Decimal,
1396 /// `None` if *any* sub-bar folded into this bucket had unknown volume —
1397 /// an unknown component makes the sum unknown, never a silent under-count.
1398 volume: Option<Decimal>,
1399 /// `None` under the same any-`None`-poisons rule as [`volume`](Self::volume).
1400 trade_count: Option<u64>,
1401 }
1402
1403 /// Emit a completed bucket as an aggregated [`Candle`], deriving its
1404 /// exclusive close boundary through the shared [`close_time_from_open`]
1405 /// helper.
1406 fn flush(bucket: Bucket, target: Duration) -> Result<Candle, AggregateCandlesError> {
1407 let out_of_range = AggregateCandlesError::TimestampOutOfRange {
1408 index: bucket.first_index,
1409 };
1410 // `open_ms` is grid-floored, so it can sit *below* a representable
1411 // sub-bar open that already passed the per-candle range check —
1412 // a distinct underflow that must surface here too.
1413 let bucket_open = DateTime::from_timestamp_millis(bucket.open_ms).ok_or(out_of_range)?;
1414 let close_time =
1415 close_time_from_open(bucket_open, IntervalStep::Fixed(target)).ok_or(out_of_range)?;
1416 Ok(Candle {
1417 close_time,
1418 open: bucket.open,
1419 high: bucket.high,
1420 low: bucket.low,
1421 close: bucket.close,
1422 volume: bucket.volume,
1423 trade_count: bucket.trade_count,
1424 })
1425 }
1426
1427 // Empty input is valid (arguments were still checked above) and needs no
1428 // allocation at all — `Vec::new()` rather than the 1-slot hint below.
1429 if candles.is_empty() {
1430 return Ok(Vec::new());
1431 }
1432
1433 // Dense-case capacity estimate (one output per `target / base_interval`
1434 // sub-bars), not an upper bound: sparse input can emit up to one bucket
1435 // per candle, in which case the `Vec` grows normally past the hint. The
1436 // `usize::MAX` fallback (quotient overflowing `usize` on 32-bit platforms,
1437 // where `usize::MAX < i64::MAX`) degrades to a capacity of 1.
1438 let sub_bars_per_bucket =
1439 usize::try_from(target_ms / base_interval.num_milliseconds()).unwrap_or(usize::MAX);
1440 let mut output = Vec::with_capacity(candles.len() / sub_bars_per_bucket + 1);
1441 let mut bucket: Option<Bucket> = None;
1442 let mut prev_close_time: Option<DateTime<Utc>> = None;
1443
1444 for (index, candle) in candles.iter().enumerate() {
1445 if prev_close_time.is_some_and(|prev| candle.close_time <= prev) {
1446 return Err(AggregateCandlesError::NonMonotonicInput { index });
1447 }
1448 prev_close_time = Some(candle.close_time);
1449
1450 // Recover the sub-bar's open through the shared boundary helper, then
1451 // snap it onto the epoch grid. `div_euclid` (not `/`) floors toward
1452 // negative infinity, which keeps pre-1970 instants correctly bucketed.
1453 // The re-multiply cannot overflow: floor-then-remultiply stays within
1454 // one `target_ms` of the input, and a representable `DateTime<Utc>` is
1455 // ~±8.3 × 10¹⁵ ms — three orders of magnitude inside `i64`.
1456 let sub_open = open_time_from_close(candle.close_time, IntervalStep::Fixed(base_interval))
1457 .ok_or(AggregateCandlesError::TimestampOutOfRange { index })?;
1458 let bucket_open_ms = sub_open.timestamp_millis().div_euclid(target_ms) * target_ms;
1459
1460 match &mut bucket {
1461 Some(current) if current.open_ms == bucket_open_ms => {
1462 current.high = current.high.max(candle.high);
1463 current.low = current.low.min(candle.low);
1464 current.close = candle.close;
1465 // Any-`None`-poisons: a bucket with one unknown component sums to
1466 // an unknown total, never a silent under-count of the known parts.
1467 current.volume = match (current.volume, candle.volume) {
1468 (Some(acc), Some(v)) => Some(acc + v),
1469 _ => None,
1470 };
1471 // A raw u64 `+=` would wrap silently in release builds (no
1472 // overflow-checks profile); a checked add keeps overflow loud
1473 // on every profile. The `expect` is the deliberate documented
1474 // panic of `# Panics`: overflow means corrupt input, not a
1475 // recoverable state. `None` on either side propagates unknown.
1476 #[allow(clippy::expect_used)]
1477 {
1478 current.trade_count = match (current.trade_count, candle.trade_count) {
1479 (Some(acc), Some(n)) => Some(
1480 acc.checked_add(n)
1481 .expect("bucket trade_count overflows u64"),
1482 ),
1483 _ => None,
1484 };
1485 }
1486 }
1487 _ => {
1488 if let Some(completed) = bucket.take() {
1489 output.push(flush(completed, target)?);
1490 }
1491 bucket = Some(Bucket {
1492 open_ms: bucket_open_ms,
1493 first_index: index,
1494 open: candle.open,
1495 high: candle.high,
1496 low: candle.low,
1497 close: candle.close,
1498 volume: candle.volume,
1499 trade_count: candle.trade_count,
1500 });
1501 }
1502 }
1503 }
1504
1505 if let Some(last) = bucket.take() {
1506 output.push(flush(last, target)?);
1507 }
1508
1509 Ok(output)
1510}
1511
1512/// Candle interval/resolution, shared across every venue that produces candles.
1513///
1514/// This is the **venue-agnostic union** of all supported candle resolutions — it
1515/// is deliberately *not* gated to any one exchange's capabilities. Per-venue
1516/// support rules (e.g. Hyperliquid rejecting `Sec1`/`Hour6`) live in the exchange
1517/// layer, not on this enum (separation of concerns). Because the enum is a union,
1518/// **each venue's interval guard must be re-reviewed whenever a variant is added.**
1519///
1520/// # String form
1521///
1522/// [`as_str`](Self::as_str) is the **single source of truth** for every string
1523/// representation: [`Display`](std::fmt::Display), [`Serialize`], [`FromStr`] and [`Deserialize`] all
1524/// delegate to it (or its inverse), so there is exactly one place mapping
1525/// variant↔string. The strings follow Binance's kline `interval` convention and are
1526/// **case-sensitive** — note `Month1 → "1M"` (uppercase) vs `Min1 → "1m"`. The union
1527/// is a superset of any one venue's menu: `Sec5`/`Sec15`/`Sec30` have no Binance
1528/// kline equivalent, so a string round-trip is *not* proof a venue serves it.
1529///
1530/// # Ordering
1531///
1532/// Variants are declared in **ascending duration** order. `Ord`/`PartialOrd` exist
1533/// only as a compile requirement (the type embeds in
1534/// [`Candles`], which must stay `Ord` to
1535/// preserve the derived `Ord` on `Subscription`); the chronological declaration
1536/// order makes the derived order at least sensible. Nothing currently sorts
1537/// intervals semantically.
1538#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
1539pub enum CandleInterval {
1540 /// 1 second
1541 Sec1,
1542 /// 5 seconds
1543 Sec5,
1544 /// 15 seconds
1545 Sec15,
1546 /// 30 seconds
1547 Sec30,
1548 /// 1 minute
1549 Min1,
1550 /// 3 minutes
1551 Min3,
1552 /// 5 minutes
1553 Min5,
1554 /// 15 minutes
1555 Min15,
1556 /// 30 minutes
1557 Min30,
1558 /// 1 hour
1559 Hour1,
1560 /// 2 hours
1561 Hour2,
1562 /// 4 hours
1563 Hour4,
1564 /// 6 hours
1565 Hour6,
1566 /// 8 hours
1567 Hour8,
1568 /// 12 hours
1569 Hour12,
1570 /// 1 day
1571 Day1,
1572 /// 3 days
1573 Day3,
1574 /// 1 week
1575 Week1,
1576 /// 1 month
1577 Month1,
1578}
1579
1580impl CandleInterval {
1581 /// Every [`CandleInterval`] variant, in ascending-duration declaration order.
1582 ///
1583 /// Lets variant-exhaustive tests (round-trip, channel-suffix drift guards)
1584 /// iterate without hand-listing the variants.
1585 ///
1586 /// When adding a [`CandleInterval`] variant, add it here too: the length
1587 /// literal and the `candle_interval_all_covers_every_variant_in_ascending_order`
1588 /// test pin `ALL`'s length to the variant count, but full coverage is not
1589 /// compile-enforced — the exhaustive `match`es elsewhere are the compile gate.
1590 pub const ALL: [CandleInterval; 19] = [
1591 Self::Sec1,
1592 Self::Sec5,
1593 Self::Sec15,
1594 Self::Sec30,
1595 Self::Min1,
1596 Self::Min3,
1597 Self::Min5,
1598 Self::Min15,
1599 Self::Min30,
1600 Self::Hour1,
1601 Self::Hour2,
1602 Self::Hour4,
1603 Self::Hour6,
1604 Self::Hour8,
1605 Self::Hour12,
1606 Self::Day1,
1607 Self::Day3,
1608 Self::Week1,
1609 Self::Month1,
1610 ];
1611
1612 /// The exchange string form of this interval (e.g. `"1m"`, `"6h"`, `"1M"`).
1613 ///
1614 /// The **single source of truth** for all string representations — `Display`,
1615 /// `Serialize`, `FromStr` and `Deserialize` all key off this. Case-sensitive:
1616 /// `Month1 → "1M"`, every other variant lowercase.
1617 #[must_use]
1618 pub fn as_str(&self) -> &'static str {
1619 match self {
1620 Self::Sec1 => "1s",
1621 Self::Sec5 => "5s",
1622 Self::Sec15 => "15s",
1623 Self::Sec30 => "30s",
1624 Self::Min1 => "1m",
1625 Self::Min3 => "3m",
1626 Self::Min5 => "5m",
1627 Self::Min15 => "15m",
1628 Self::Min30 => "30m",
1629 Self::Hour1 => "1h",
1630 Self::Hour2 => "2h",
1631 Self::Hour4 => "4h",
1632 Self::Hour6 => "6h",
1633 Self::Hour8 => "8h",
1634 Self::Hour12 => "12h",
1635 Self::Day1 => "1d",
1636 Self::Day3 => "3d",
1637 Self::Week1 => "1w",
1638 Self::Month1 => "1M",
1639 }
1640 }
1641
1642 /// Map this interval to the shared [`IntervalStep`] used to compute a candle's
1643 /// exclusive `close_time` boundary via [`close_time_from_open`]. All intervals
1644 /// are fixed-length except `1M`, which is a calendar month.
1645 #[must_use]
1646 pub fn to_step(self) -> IntervalStep {
1647 match self {
1648 Self::Sec1 => IntervalStep::Fixed(Duration::seconds(1)),
1649 Self::Sec5 => IntervalStep::Fixed(Duration::seconds(5)),
1650 Self::Sec15 => IntervalStep::Fixed(Duration::seconds(15)),
1651 Self::Sec30 => IntervalStep::Fixed(Duration::seconds(30)),
1652 Self::Min1 => IntervalStep::Fixed(Duration::minutes(1)),
1653 Self::Min3 => IntervalStep::Fixed(Duration::minutes(3)),
1654 Self::Min5 => IntervalStep::Fixed(Duration::minutes(5)),
1655 Self::Min15 => IntervalStep::Fixed(Duration::minutes(15)),
1656 Self::Min30 => IntervalStep::Fixed(Duration::minutes(30)),
1657 Self::Hour1 => IntervalStep::Fixed(Duration::hours(1)),
1658 Self::Hour2 => IntervalStep::Fixed(Duration::hours(2)),
1659 Self::Hour4 => IntervalStep::Fixed(Duration::hours(4)),
1660 Self::Hour6 => IntervalStep::Fixed(Duration::hours(6)),
1661 Self::Hour8 => IntervalStep::Fixed(Duration::hours(8)),
1662 Self::Hour12 => IntervalStep::Fixed(Duration::hours(12)),
1663 Self::Day1 => IntervalStep::Fixed(Duration::days(1)),
1664 Self::Day3 => IntervalStep::Fixed(Duration::days(3)),
1665 Self::Week1 => IntervalStep::Fixed(Duration::weeks(1)),
1666 Self::Month1 => IntervalStep::Months(1),
1667 }
1668 }
1669}
1670
1671impl std::fmt::Display for CandleInterval {
1672 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1673 f.write_str(self.as_str())
1674 }
1675}
1676
1677/// Error returned by [`CandleInterval::from_str`] for an unrecognised string.
1678///
1679/// The offending input is kept private and exposed via [`input`](Self::input) so
1680/// the error's representation can evolve (e.g. gaining context) without a
1681/// breaking change — mirroring `std`'s opaque parse-error types.
1682#[derive(Debug, Clone, PartialEq, Eq)]
1683pub struct ParseCandleIntervalError {
1684 invalid: String,
1685}
1686
1687impl ParseCandleIntervalError {
1688 /// The input string that failed to parse.
1689 #[must_use]
1690 pub fn input(&self) -> &str {
1691 &self.invalid
1692 }
1693}
1694
1695impl std::fmt::Display for ParseCandleIntervalError {
1696 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1697 write!(f, "invalid candle interval: {:?}", self.invalid)
1698 }
1699}
1700
1701impl std::error::Error for ParseCandleIntervalError {}
1702
1703impl FromStr for CandleInterval {
1704 type Err = ParseCandleIntervalError;
1705
1706 /// The inverse of [`CandleInterval::as_str`] — case-sensitive (`"1M"` is the
1707 /// only uppercase form). Keeps variant↔string mapping in exactly one place.
1708 fn from_str(s: &str) -> Result<Self, Self::Err> {
1709 match s {
1710 "1s" => Ok(Self::Sec1),
1711 "5s" => Ok(Self::Sec5),
1712 "15s" => Ok(Self::Sec15),
1713 "30s" => Ok(Self::Sec30),
1714 "1m" => Ok(Self::Min1),
1715 "3m" => Ok(Self::Min3),
1716 "5m" => Ok(Self::Min5),
1717 "15m" => Ok(Self::Min15),
1718 "30m" => Ok(Self::Min30),
1719 "1h" => Ok(Self::Hour1),
1720 "2h" => Ok(Self::Hour2),
1721 "4h" => Ok(Self::Hour4),
1722 "6h" => Ok(Self::Hour6),
1723 "8h" => Ok(Self::Hour8),
1724 "12h" => Ok(Self::Hour12),
1725 "1d" => Ok(Self::Day1),
1726 "3d" => Ok(Self::Day3),
1727 "1w" => Ok(Self::Week1),
1728 "1M" => Ok(Self::Month1),
1729 other => Err(ParseCandleIntervalError {
1730 invalid: other.to_owned(),
1731 }),
1732 }
1733 }
1734}
1735
1736impl Serialize for CandleInterval {
1737 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1738 where
1739 S: Serializer,
1740 {
1741 serializer.serialize_str(self.as_str())
1742 }
1743}
1744
1745impl<'de> Deserialize<'de> for CandleInterval {
1746 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1747 where
1748 D: Deserializer<'de>,
1749 {
1750 let raw = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
1751 raw.parse().map_err(de::Error::custom)
1752 }
1753}