use super::SubscriptionKind;
use chrono::{DateTime, Duration, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use std::str::FromStr;
#[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;
fn dt(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[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(), 16);
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");
}
}
#[derive(Copy, Clone, PartialEq, PartialOrd, 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: Decimal,
pub trade_count: 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, Hash, Ord, PartialOrd)]
pub enum CandleInterval {
Sec1,
Min1,
Min3,
Min5,
Min15,
Min30,
Hour1,
Hour2,
Hour4,
Hour6,
Hour8,
Hour12,
Day1,
Day3,
Week1,
Month1,
}
impl CandleInterval {
pub const ALL: [CandleInterval; 16] = [
Self::Sec1,
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::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::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),
"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)
}
}