Skip to main content

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}