use super::SubscriptionKind;
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::str::FromStr;
use thiserror::Error;
#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
pub struct Candles {
pub interval: CandleInterval,
}
impl SubscriptionKind for Candles {
type Event = Candle;
fn as_str(&self) -> &'static str {
"candles"
}
}
impl std::fmt::Display for Candles {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
use chrono::Duration;
use rust_decimal_macros::dec;
fn dt(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
fn bar(
close_time: &str,
open: Decimal,
high: Decimal,
low: Decimal,
close: Decimal,
volume: Decimal,
trade_count: u64,
) -> Candle {
Candle {
close_time: dt(close_time),
open,
high,
low,
close,
volume: Some(volume),
trade_count: Some(trade_count),
}
}
#[test]
fn fixed_step_adds_duration_exactly() {
let open = dt("2024-01-15T12:00:00Z");
assert_eq!(
close_time_from_open(open, IntervalStep::Fixed(Duration::minutes(1))),
Some(dt("2024-01-15T12:01:00Z"))
);
assert_eq!(
close_time_from_open(open, IntervalStep::Fixed(Duration::hours(1))),
Some(dt("2024-01-15T13:00:00Z"))
);
assert_eq!(
close_time_from_open(
dt("2024-01-15T00:00:00Z"),
IntervalStep::Fixed(Duration::days(1))
),
Some(dt("2024-01-16T00:00:00Z"))
);
assert_eq!(
close_time_from_open(
dt("2024-01-15T00:00:00Z"),
IntervalStep::Fixed(Duration::weeks(1))
),
Some(dt("2024-01-22T00:00:00Z"))
);
}
#[test]
fn fixed_daily_step_crosses_month_boundary() {
assert_eq!(
close_time_from_open(
dt("2024-01-31T00:00:00Z"),
IntervalStep::Fixed(Duration::days(1))
),
Some(dt("2024-02-01T00:00:00Z"))
);
}
#[test]
fn months_step_uses_calendar_arithmetic() {
assert_eq!(
close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(1)),
Some(dt("2024-02-01T00:00:00Z"))
);
assert_eq!(
close_time_from_open(dt("2024-02-01T00:00:00Z"), IntervalStep::Months(1)),
Some(dt("2024-03-01T00:00:00Z"))
);
assert_eq!(
close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(3)),
Some(dt("2024-04-01T00:00:00Z"))
);
assert_eq!(
close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(12)),
Some(dt("2025-01-01T00:00:00Z"))
);
}
#[test]
fn months_step_clamps_jan_31_anchor() {
assert_eq!(
close_time_from_open(dt("2024-01-31T00:00:00Z"), IntervalStep::Months(1)),
Some(dt("2024-02-29T00:00:00Z"))
);
}
#[test]
fn overflow_returns_none_not_panic() {
let max = DateTime::<Utc>::MAX_UTC;
assert_eq!(close_time_from_open(max, IntervalStep::Months(1)), None);
assert_eq!(
close_time_from_open(max, IntervalStep::Fixed(Duration::days(1))),
None
);
}
#[test]
fn open_time_from_close_is_inverse() {
assert_eq!(
open_time_from_close(
dt("2024-01-15T13:00:00Z"),
IntervalStep::Fixed(Duration::hours(1))
),
Some(dt("2024-01-15T12:00:00Z"))
);
assert_eq!(
open_time_from_close(dt("2024-02-01T00:00:00Z"), IntervalStep::Months(1)),
Some(dt("2024-01-01T00:00:00Z"))
);
let close = dt("2024-04-01T00:00:00Z");
let open = open_time_from_close(close, IntervalStep::Months(3)).unwrap();
assert_eq!(
close_time_from_open(open, IntervalStep::Months(3)),
Some(close)
);
}
#[test]
fn open_time_from_close_underflow_returns_none() {
let min = DateTime::<Utc>::MIN_UTC;
assert_eq!(open_time_from_close(min, IntervalStep::Months(1)), None);
assert_eq!(
open_time_from_close(min, IntervalStep::Fixed(Duration::days(1))),
None
);
}
#[test]
fn candle_interval_all_covers_every_variant_in_ascending_order() {
assert_eq!(CandleInterval::ALL.len(), 19);
fn approx_secs(interval: CandleInterval) -> i64 {
match interval.to_step() {
IntervalStep::Fixed(d) => d.num_seconds(),
IntervalStep::Months(n) => i64::from(n) * 30 * 24 * 60 * 60,
}
}
for pair in CandleInterval::ALL.windows(2) {
assert!(
approx_secs(pair[0]) < approx_secs(pair[1]),
"ALL must be in strictly ascending duration order: {:?} !< {:?}",
pair[0],
pair[1]
);
}
}
#[test]
fn candle_interval_display_equals_as_str_for_every_variant() {
for interval in CandleInterval::ALL {
assert_eq!(interval.to_string(), interval.as_str());
}
}
#[test]
fn candle_interval_as_str_matches_binance_exactly() {
assert_eq!(CandleInterval::Sec1.as_str(), "1s");
assert_eq!(CandleInterval::Min1.as_str(), "1m");
assert_eq!(CandleInterval::Hour6.as_str(), "6h");
assert_eq!(CandleInterval::Month1.as_str(), "1M");
}
#[test]
fn candle_interval_from_str_is_inverse_of_as_str() {
for interval in CandleInterval::ALL {
assert_eq!(interval.as_str().parse::<CandleInterval>(), Ok(interval));
}
}
#[test]
fn candle_interval_from_str_rejects_garbage() {
assert!("".parse::<CandleInterval>().is_err());
assert!("7m".parse::<CandleInterval>().is_err());
assert_eq!("1m".parse::<CandleInterval>(), Ok(CandleInterval::Min1));
assert_eq!("1M".parse::<CandleInterval>(), Ok(CandleInterval::Month1));
}
#[test]
fn candle_interval_serde_round_trips_every_variant() {
for interval in CandleInterval::ALL {
let json = serde_json::to_string(&interval).unwrap();
assert_eq!(json, format!("\"{}\"", interval.as_str()));
let back: CandleInterval = serde_json::from_str(&json).unwrap();
assert_eq!(back, interval);
}
}
#[test]
fn candles_kind_carries_interval_and_serde_round_trips() {
let kind = Candles {
interval: CandleInterval::Hour6,
};
let json = serde_json::to_string(&kind).unwrap();
assert_eq!(json, r#"{"interval":"6h"}"#);
let back: Candles = serde_json::from_str(&json).unwrap();
assert_eq!(back, kind);
assert_eq!(kind.as_str(), "candles");
assert_eq!(kind.to_string(), "candles");
}
#[test]
fn aggregate_rolls_three_1s_bars_into_one_3s_bar() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
5,
),
bar(
"2024-01-15T12:00:02Z",
dec!(11),
dec!(15),
dec!(10),
dec!(14),
dec!(2),
3,
),
bar(
"2024-01-15T12:00:03Z",
dec!(14),
dec!(16),
dec!(13),
dec!(15),
dec!(3),
2,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![bar(
"2024-01-15T12:00:03Z",
dec!(10),
dec!(16),
dec!(9),
dec!(15),
dec!(6),
10
)]
);
}
#[test]
fn aggregate_splits_six_1s_bars_into_two_3s_bars() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
),
bar(
"2024-01-15T12:00:02Z",
dec!(11),
dec!(15),
dec!(10),
dec!(14),
dec!(2),
2,
),
bar(
"2024-01-15T12:00:03Z",
dec!(14),
dec!(16),
dec!(13),
dec!(15),
dec!(3),
3,
),
bar(
"2024-01-15T12:00:04Z",
dec!(15),
dec!(17),
dec!(14),
dec!(16),
dec!(4),
4,
),
bar(
"2024-01-15T12:00:05Z",
dec!(16),
dec!(18),
dec!(15),
dec!(17),
dec!(5),
5,
),
bar(
"2024-01-15T12:00:06Z",
dec!(17),
dec!(19),
dec!(12),
dec!(13),
dec!(6),
6,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![
bar(
"2024-01-15T12:00:03Z",
dec!(10),
dec!(16),
dec!(9),
dec!(15),
dec!(6),
6
),
bar(
"2024-01-15T12:00:06Z",
dec!(15),
dec!(19),
dec!(12),
dec!(13),
dec!(15),
15
),
]
);
}
#[test]
fn aggregate_buckets_snap_to_the_epoch_grid_not_the_first_sample() {
let bars = [
bar(
"2024-01-15T12:00:02Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
),
bar(
"2024-01-15T12:00:03Z",
dec!(11),
dec!(15),
dec!(10),
dec!(14),
dec!(2),
2,
),
bar(
"2024-01-15T12:00:04Z",
dec!(14),
dec!(16),
dec!(13),
dec!(15),
dec!(4),
3,
),
];
let target = Duration::seconds(3);
let out = aggregate_candles(&bars, Duration::seconds(1), target).unwrap();
assert_eq!(out.len(), 2);
assert_eq!(out[0].close_time, dt("2024-01-15T12:00:03Z"));
assert_eq!(out[0].volume, Some(dec!(3)));
assert_eq!(out[1].close_time, dt("2024-01-15T12:00:06Z"));
assert_eq!(out[1].volume, Some(dec!(4)));
for candle in &out {
assert_eq!(
candle
.close_time
.timestamp_millis()
.rem_euclid(target.num_milliseconds()),
0
);
}
}
#[test]
fn aggregate_partial_bucket_does_not_scale_volume_to_bucket_width() {
let bars = [bar(
"2024-01-15T12:00:02Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(5),
4,
)];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![bar(
"2024-01-15T12:00:03Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(5),
4
)]
);
}
#[test]
fn aggregate_omits_fully_empty_buckets() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
),
bar(
"2024-01-15T12:00:11Z",
dec!(20),
dec!(22),
dec!(19),
dec!(21),
dec!(2),
2,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![
bar(
"2024-01-15T12:00:03Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1
),
bar(
"2024-01-15T12:00:12Z",
dec!(20),
dec!(22),
dec!(19),
dec!(21),
dec!(2),
2
),
]
);
}
#[test]
fn aggregate_rejects_invalid_interval_arguments() {
let base = Duration::seconds(2);
assert_eq!(
aggregate_candles(&[], base, Duration::seconds(5)).unwrap_err(),
AggregateCandlesError::TargetNotMultipleOfBase {
base,
target: Duration::seconds(5)
}
);
assert_eq!(
aggregate_candles(&[], base, Duration::seconds(1)).unwrap_err(),
AggregateCandlesError::TargetSmallerThanBase {
base,
target: Duration::seconds(1)
}
);
assert_eq!(
aggregate_candles(&[], Duration::zero(), Duration::seconds(3)).unwrap_err(),
AggregateCandlesError::NonPositiveInterval {
base: Duration::zero(),
target: Duration::seconds(3)
}
);
assert_eq!(
aggregate_candles(&[], base, Duration::seconds(-3)).unwrap_err(),
AggregateCandlesError::NonPositiveInterval {
base,
target: Duration::seconds(-3)
}
);
}
#[test]
fn aggregate_rejects_sub_millisecond_intervals() {
let base = Duration::nanoseconds(1_500_000);
let target = Duration::milliseconds(3);
assert_eq!(
aggregate_candles(&[], base, target).unwrap_err(),
AggregateCandlesError::SubMillisecondInterval { base, target }
);
let base = Duration::milliseconds(1);
let target = Duration::nanoseconds(2_500_000);
assert_eq!(
aggregate_candles(&[], base, target).unwrap_err(),
AggregateCandlesError::SubMillisecondInterval { base, target }
);
}
#[test]
fn aggregate_of_empty_input_is_empty() {
let out = aggregate_candles(&[], Duration::seconds(1), Duration::seconds(3)).unwrap();
assert!(out.is_empty());
}
#[test]
fn aggregate_close_time_round_trips_through_the_shared_boundary_helper() {
let bars = [bar(
"2024-01-15T12:00:01Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
)];
let target = Duration::seconds(3);
let out = aggregate_candles(&bars, Duration::seconds(1), target).unwrap();
assert_eq!(
out[0].close_time,
close_time_from_open(dt("2024-01-15T12:00:00Z"), IntervalStep::Fixed(target)).unwrap()
);
}
#[test]
fn aggregate_surfaces_bucket_close_overflow_as_an_error() {
let target = Duration::seconds(3);
let bucket_open_ms = DateTime::<Utc>::MAX_UTC
.timestamp_millis()
.div_euclid(target.num_milliseconds())
* target.num_milliseconds();
let close_time = DateTime::from_timestamp_millis(bucket_open_ms + 1).unwrap();
let bars = [Candle {
close_time,
open: dec!(1),
high: dec!(1),
low: dec!(1),
close: dec!(1),
volume: Some(dec!(1)),
trade_count: Some(1),
}];
assert_eq!(
aggregate_candles(&bars, Duration::milliseconds(1), target).unwrap_err(),
AggregateCandlesError::TimestampOutOfRange { index: 0 }
);
}
#[test]
fn aggregate_sixty_1s_bars_matches_the_fields_of_a_native_1m_bar() {
let session_open = dt("2024-01-15T12:00:00Z");
let bars = (0..60i64)
.map(|i| Candle {
close_time: session_open + Duration::seconds(i + 1),
open: Decimal::from(100 + i),
high: Decimal::from(105 + i),
low: Decimal::from(95 + i),
close: Decimal::from(101 + i),
volume: Some(Decimal::from(2)),
trade_count: Some(3),
})
.collect::<Vec<_>>();
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::minutes(1)).unwrap();
assert_eq!(
out,
vec![Candle {
close_time: dt("2024-01-15T12:01:00Z"),
open: Decimal::from(100),
high: Decimal::from(164),
low: Decimal::from(95),
close: Decimal::from(160),
volume: Some(Decimal::from(120)),
trade_count: Some(180),
}]
);
}
#[test]
fn aggregate_any_none_constituent_poisons_the_bucket() {
let cell = |close: &str, v: Option<Decimal>, n: Option<u64>| Candle {
close_time: dt(close),
open: dec!(1),
high: dec!(1),
low: dec!(1),
close: dec!(1),
volume: v,
trade_count: n,
};
let bars = [
cell("2024-01-15T12:00:01Z", Some(dec!(1)), Some(1)),
cell("2024-01-15T12:00:02Z", None, Some(2)), cell("2024-01-15T12:00:03Z", Some(dec!(3)), None), ];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(
out[0].volume, None,
"one None-volume constituent poisons the summed volume"
);
assert_eq!(
out[0].trade_count, None,
"one None-trade_count constituent poisons the summed trade_count"
);
}
#[test]
fn aggregate_all_known_constituents_sum_normally() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(1),
dec!(1),
dec!(1),
dec!(1),
dec!(2),
5,
),
bar(
"2024-01-15T12:00:02Z",
dec!(1),
dec!(1),
dec!(1),
dec!(1),
dec!(3),
7,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].volume, Some(dec!(5)));
assert_eq!(out[0].trade_count, Some(12));
}
#[test]
fn aggregate_gap_filled_input_carries_fills_without_double_counting() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(90),
dec!(90),
dec!(90),
dec!(90),
dec!(0),
0,
),
bar(
"2024-01-15T12:00:02Z",
dec!(100),
dec!(101),
dec!(99),
dec!(100.5),
dec!(10),
7,
),
bar(
"2024-01-15T12:00:03Z",
dec!(100.5),
dec!(100.5),
dec!(100.5),
dec!(100.5),
dec!(0),
0,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![bar(
"2024-01-15T12:00:03Z",
dec!(90),
dec!(101),
dec!(90),
dec!(100.5),
dec!(10),
7
)]
);
}
#[test]
fn aggregate_bucket_of_only_zero_volume_bars_emits_one_flat_bar() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(50),
dec!(50),
dec!(50),
dec!(50),
dec!(0),
0,
),
bar(
"2024-01-15T12:00:02Z",
dec!(50),
dec!(50),
dec!(50),
dec!(50),
dec!(0),
0,
),
bar(
"2024-01-15T12:00:03Z",
dec!(50),
dec!(50),
dec!(50),
dec!(50),
dec!(0),
0,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![bar(
"2024-01-15T12:00:03Z",
dec!(50),
dec!(50),
dec!(50),
dec!(50),
dec!(0),
0
)]
);
}
#[test]
fn aggregate_rejects_unsorted_and_duplicate_input_observably() {
let first = bar(
"2024-01-15T12:00:02Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
);
let second = bar(
"2024-01-15T12:00:01Z",
dec!(11),
dec!(15),
dec!(10),
dec!(14),
dec!(2),
2,
);
assert_eq!(
aggregate_candles(&[first, second], Duration::seconds(1), Duration::seconds(3))
.unwrap_err(),
AggregateCandlesError::NonMonotonicInput { index: 1 }
);
assert_eq!(
aggregate_candles(&[first, first], Duration::seconds(1), Duration::seconds(3))
.unwrap_err(),
AggregateCandlesError::NonMonotonicInput { index: 1 }
);
}
#[test]
fn aggregate_surfaces_sub_bar_open_underflow_as_an_error() {
let bars = [Candle {
close_time: DateTime::<Utc>::MIN_UTC,
open: dec!(1),
high: dec!(1),
low: dec!(1),
close: dec!(1),
volume: Some(dec!(1)),
trade_count: Some(1),
}];
assert_eq!(
aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap_err(),
AggregateCandlesError::TimestampOutOfRange { index: 0 }
);
}
#[test]
fn aggregate_surfaces_bucket_open_underflow_from_grid_flooring_as_an_error() {
let base = Duration::milliseconds(1);
let bars = [Candle {
close_time: DateTime::<Utc>::MIN_UTC + base,
open: dec!(1),
high: dec!(1),
low: dec!(1),
close: dec!(1),
volume: Some(dec!(1)),
trade_count: Some(1),
}];
assert_eq!(
aggregate_candles(&bars, base, Duration::days(3)).unwrap_err(),
AggregateCandlesError::TimestampOutOfRange { index: 0 }
);
}
#[test]
fn aggregate_floors_pre_epoch_opens_toward_negative_infinity() {
let bars = [bar(
"1970-01-01T00:00:00Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
)];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(3)).unwrap();
assert_eq!(
out,
vec![bar(
"1970-01-01T00:00:00Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1
)]
);
}
#[test]
fn aggregate_with_target_equal_to_base_is_an_identity_regrid() {
let bars = [
bar(
"2024-01-15T12:00:01Z",
dec!(10),
dec!(12),
dec!(9),
dec!(11),
dec!(1),
1,
),
bar(
"2024-01-15T12:00:02Z",
dec!(11),
dec!(15),
dec!(10),
dec!(14),
dec!(2),
2,
),
];
let out = aggregate_candles(&bars, Duration::seconds(1), Duration::seconds(1)).unwrap();
assert_eq!(out, bars.to_vec());
}
#[test]
fn an_absent_volume_or_trade_count_deserialises_as_unknown_and_a_zero_stays_a_zero() {
let without = serde_json::json!({
"close_time": "2024-01-15T12:00:00Z",
"open": "10", "high": "12", "low": "9", "close": "11",
});
let candle: Candle = serde_json::from_value(without).unwrap();
assert_eq!(candle.volume, None);
assert_eq!(candle.trade_count, None);
let pre_migration = serde_json::json!({
"close_time": "2024-01-15T12:00:00Z",
"open": "10", "high": "12", "low": "9", "close": "11",
"volume": "0", "trade_count": 0,
});
let candle: Candle = serde_json::from_value(pre_migration).unwrap();
assert_eq!(candle.volume, Some(Decimal::ZERO));
assert_eq!(candle.trade_count, Some(0));
let unknown = Candle {
close_time: dt("2024-01-15T12:00:00Z"),
open: dec!(10),
high: dec!(12),
low: dec!(9),
close: dec!(11),
volume: None,
trade_count: None,
};
let encoded = serde_json::to_value(unknown).unwrap();
assert_eq!(encoded["volume"], serde_json::Value::Null);
assert_eq!(encoded["trade_count"], serde_json::Value::Null);
assert_eq!(serde_json::from_value::<Candle>(encoded).unwrap(), unknown);
}
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Hash, Debug, Deserialize, Serialize)]
pub struct Candle {
pub close_time: DateTime<Utc>,
pub open: Decimal,
pub high: Decimal,
pub low: Decimal,
pub close: Decimal,
pub volume: Option<Decimal>,
pub trade_count: Option<u64>,
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum IntervalStep {
Fixed(chrono::Duration),
Months(u32),
}
#[must_use]
pub fn close_time_from_open(open: DateTime<Utc>, step: IntervalStep) -> Option<DateTime<Utc>> {
match step {
IntervalStep::Fixed(duration) => open.checked_add_signed(duration),
IntervalStep::Months(n) => open.checked_add_months(chrono::Months::new(n)),
}
}
#[must_use]
pub fn open_time_from_close(close: DateTime<Utc>, step: IntervalStep) -> Option<DateTime<Utc>> {
match step {
IntervalStep::Fixed(duration) => close.checked_sub_signed(duration),
IntervalStep::Months(n) => close.checked_sub_months(chrono::Months::new(n)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
#[non_exhaustive]
pub enum AggregateCandlesError {
#[error("candle intervals must be positive (base: {base}, target: {target})")]
NonPositiveInterval {
base: Duration,
target: Duration,
},
#[error("candle intervals must be whole milliseconds (base: {base}, target: {target})")]
SubMillisecondInterval {
base: Duration,
target: Duration,
},
#[error(
"target interval must not be smaller than the base interval (base: {base}, target: {target})"
)]
TargetSmallerThanBase {
base: Duration,
target: Duration,
},
#[error(
"target interval must be an integer multiple of the base interval (base: {base}, target: {target})"
)]
TargetNotMultipleOfBase {
base: Duration,
target: Duration,
},
#[error("input candles must be strictly ascending by close_time (violation at index {index})")]
NonMonotonicInput {
index: usize,
},
#[error(
"candle at index {index} yields a boundary outside the representable DateTime<Utc> range"
)]
TimestampOutOfRange {
index: usize,
},
}
pub fn aggregate_candles(
candles: &[Candle],
base_interval: Duration,
target: Duration,
) -> Result<Vec<Candle>, AggregateCandlesError> {
if base_interval <= Duration::zero() || target <= Duration::zero() {
return Err(AggregateCandlesError::NonPositiveInterval {
base: base_interval,
target,
});
}
if Duration::milliseconds(base_interval.num_milliseconds()) != base_interval
|| Duration::milliseconds(target.num_milliseconds()) != target
{
return Err(AggregateCandlesError::SubMillisecondInterval {
base: base_interval,
target,
});
}
if target < base_interval {
return Err(AggregateCandlesError::TargetSmallerThanBase {
base: base_interval,
target,
});
}
let target_ms = target.num_milliseconds();
if target_ms % base_interval.num_milliseconds() != 0 {
return Err(AggregateCandlesError::TargetNotMultipleOfBase {
base: base_interval,
target,
});
}
struct Bucket {
open_ms: i64,
first_index: usize,
open: Decimal,
high: Decimal,
low: Decimal,
close: Decimal,
volume: Option<Decimal>,
trade_count: Option<u64>,
}
fn flush(bucket: Bucket, target: Duration) -> Result<Candle, AggregateCandlesError> {
let out_of_range = AggregateCandlesError::TimestampOutOfRange {
index: bucket.first_index,
};
let bucket_open = DateTime::from_timestamp_millis(bucket.open_ms).ok_or(out_of_range)?;
let close_time =
close_time_from_open(bucket_open, IntervalStep::Fixed(target)).ok_or(out_of_range)?;
Ok(Candle {
close_time,
open: bucket.open,
high: bucket.high,
low: bucket.low,
close: bucket.close,
volume: bucket.volume,
trade_count: bucket.trade_count,
})
}
if candles.is_empty() {
return Ok(Vec::new());
}
let sub_bars_per_bucket =
usize::try_from(target_ms / base_interval.num_milliseconds()).unwrap_or(usize::MAX);
let mut output = Vec::with_capacity(candles.len() / sub_bars_per_bucket + 1);
let mut bucket: Option<Bucket> = None;
let mut prev_close_time: Option<DateTime<Utc>> = None;
for (index, candle) in candles.iter().enumerate() {
if prev_close_time.is_some_and(|prev| candle.close_time <= prev) {
return Err(AggregateCandlesError::NonMonotonicInput { index });
}
prev_close_time = Some(candle.close_time);
let sub_open = open_time_from_close(candle.close_time, IntervalStep::Fixed(base_interval))
.ok_or(AggregateCandlesError::TimestampOutOfRange { index })?;
let bucket_open_ms = sub_open.timestamp_millis().div_euclid(target_ms) * target_ms;
match &mut bucket {
Some(current) if current.open_ms == bucket_open_ms => {
current.high = current.high.max(candle.high);
current.low = current.low.min(candle.low);
current.close = candle.close;
current.volume = match (current.volume, candle.volume) {
(Some(acc), Some(v)) => Some(acc + v),
_ => None,
};
#[allow(clippy::expect_used)]
{
current.trade_count = match (current.trade_count, candle.trade_count) {
(Some(acc), Some(n)) => Some(
acc.checked_add(n)
.expect("bucket trade_count overflows u64"),
),
_ => None,
};
}
}
_ => {
if let Some(completed) = bucket.take() {
output.push(flush(completed, target)?);
}
bucket = Some(Bucket {
open_ms: bucket_open_ms,
first_index: index,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume: candle.volume,
trade_count: candle.trade_count,
});
}
}
}
if let Some(last) = bucket.take() {
output.push(flush(last, target)?);
}
Ok(output)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
pub enum CandleInterval {
Sec1,
Sec5,
Sec15,
Sec30,
Min1,
Min3,
Min5,
Min15,
Min30,
Hour1,
Hour2,
Hour4,
Hour6,
Hour8,
Hour12,
Day1,
Day3,
Week1,
Month1,
}
impl CandleInterval {
pub const ALL: [CandleInterval; 19] = [
Self::Sec1,
Self::Sec5,
Self::Sec15,
Self::Sec30,
Self::Min1,
Self::Min3,
Self::Min5,
Self::Min15,
Self::Min30,
Self::Hour1,
Self::Hour2,
Self::Hour4,
Self::Hour6,
Self::Hour8,
Self::Hour12,
Self::Day1,
Self::Day3,
Self::Week1,
Self::Month1,
];
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Sec1 => "1s",
Self::Sec5 => "5s",
Self::Sec15 => "15s",
Self::Sec30 => "30s",
Self::Min1 => "1m",
Self::Min3 => "3m",
Self::Min5 => "5m",
Self::Min15 => "15m",
Self::Min30 => "30m",
Self::Hour1 => "1h",
Self::Hour2 => "2h",
Self::Hour4 => "4h",
Self::Hour6 => "6h",
Self::Hour8 => "8h",
Self::Hour12 => "12h",
Self::Day1 => "1d",
Self::Day3 => "3d",
Self::Week1 => "1w",
Self::Month1 => "1M",
}
}
#[must_use]
pub fn to_step(self) -> IntervalStep {
match self {
Self::Sec1 => IntervalStep::Fixed(Duration::seconds(1)),
Self::Sec5 => IntervalStep::Fixed(Duration::seconds(5)),
Self::Sec15 => IntervalStep::Fixed(Duration::seconds(15)),
Self::Sec30 => IntervalStep::Fixed(Duration::seconds(30)),
Self::Min1 => IntervalStep::Fixed(Duration::minutes(1)),
Self::Min3 => IntervalStep::Fixed(Duration::minutes(3)),
Self::Min5 => IntervalStep::Fixed(Duration::minutes(5)),
Self::Min15 => IntervalStep::Fixed(Duration::minutes(15)),
Self::Min30 => IntervalStep::Fixed(Duration::minutes(30)),
Self::Hour1 => IntervalStep::Fixed(Duration::hours(1)),
Self::Hour2 => IntervalStep::Fixed(Duration::hours(2)),
Self::Hour4 => IntervalStep::Fixed(Duration::hours(4)),
Self::Hour6 => IntervalStep::Fixed(Duration::hours(6)),
Self::Hour8 => IntervalStep::Fixed(Duration::hours(8)),
Self::Hour12 => IntervalStep::Fixed(Duration::hours(12)),
Self::Day1 => IntervalStep::Fixed(Duration::days(1)),
Self::Day3 => IntervalStep::Fixed(Duration::days(3)),
Self::Week1 => IntervalStep::Fixed(Duration::weeks(1)),
Self::Month1 => IntervalStep::Months(1),
}
}
}
impl std::fmt::Display for CandleInterval {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseCandleIntervalError {
invalid: String,
}
impl ParseCandleIntervalError {
#[must_use]
pub fn input(&self) -> &str {
&self.invalid
}
}
impl std::fmt::Display for ParseCandleIntervalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "invalid candle interval: {:?}", self.invalid)
}
}
impl std::error::Error for ParseCandleIntervalError {}
impl FromStr for CandleInterval {
type Err = ParseCandleIntervalError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"1s" => Ok(Self::Sec1),
"5s" => Ok(Self::Sec5),
"15s" => Ok(Self::Sec15),
"30s" => Ok(Self::Sec30),
"1m" => Ok(Self::Min1),
"3m" => Ok(Self::Min3),
"5m" => Ok(Self::Min5),
"15m" => Ok(Self::Min15),
"30m" => Ok(Self::Min30),
"1h" => Ok(Self::Hour1),
"2h" => Ok(Self::Hour2),
"4h" => Ok(Self::Hour4),
"6h" => Ok(Self::Hour6),
"8h" => Ok(Self::Hour8),
"12h" => Ok(Self::Hour12),
"1d" => Ok(Self::Day1),
"3d" => Ok(Self::Day3),
"1w" => Ok(Self::Week1),
"1M" => Ok(Self::Month1),
other => Err(ParseCandleIntervalError {
invalid: other.to_owned(),
}),
}
}
}
impl Serialize for CandleInterval {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for CandleInterval {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
raw.parse().map_err(de::Error::custom)
}
}