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;
6
7/// Barter [`Subscription`](super::Subscription) [`SubscriptionKind`] that yields [`Candle`]
8/// [`MarketEvent<T>`](crate::event::MarketEvent) events.
9///
10/// The [`interval`](Self::interval) is intrinsic to a candle subscription — it is
11/// the resolution being streamed, so there is no meaningful default (a phantom
12/// "1m" default is a silent-bug footgun); the field is always explicit.
13#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Deserialize, Serialize)]
14pub struct Candles {
15    /// The candle resolution to subscribe to. See [`CandleInterval`].
16    pub interval: CandleInterval,
17}
18
19impl SubscriptionKind for Candles {
20    type Event = Candle;
21
22    /// Returns the fixed kind tag `"candles"`, independent of [`interval`](Self::interval).
23    /// The tag identifies the subscription *kind* for routing and stays stable across
24    /// resolutions; it is **not** the interval. For the resolution string use
25    /// [`CandleInterval::as_str`] on the [`interval`](Self::interval) field — note that
26    /// [`Display`](std::fmt::Display) for `Candles` also yields only `"candles"`.
27    fn as_str(&self) -> &'static str {
28        "candles"
29    }
30}
31
32impl std::fmt::Display for Candles {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.write_str(self.as_str())
35    }
36}
37
38#[cfg(test)]
39#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
40mod tests {
41    use super::*;
42    use chrono::Duration;
43
44    /// Parse an RFC3339 UTC instant in tests.
45    fn dt(s: &str) -> DateTime<Utc> {
46        DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
47    }
48
49    #[test]
50    fn fixed_step_adds_duration_exactly() {
51        let open = dt("2024-01-15T12:00:00Z");
52        assert_eq!(
53            close_time_from_open(open, IntervalStep::Fixed(Duration::minutes(1))),
54            Some(dt("2024-01-15T12:01:00Z"))
55        );
56        assert_eq!(
57            close_time_from_open(open, IntervalStep::Fixed(Duration::hours(1))),
58            Some(dt("2024-01-15T13:00:00Z"))
59        );
60        // Daily/weekly are exact fixed durations in UTC (no DST).
61        assert_eq!(
62            close_time_from_open(
63                dt("2024-01-15T00:00:00Z"),
64                IntervalStep::Fixed(Duration::days(1))
65            ),
66            Some(dt("2024-01-16T00:00:00Z"))
67        );
68        assert_eq!(
69            close_time_from_open(
70                dt("2024-01-15T00:00:00Z"),
71                IntervalStep::Fixed(Duration::weeks(1))
72            ),
73            Some(dt("2024-01-22T00:00:00Z"))
74        );
75    }
76
77    #[test]
78    fn fixed_daily_step_crosses_month_boundary() {
79        // A Jan 31 daily bar closes at Feb 1 00:00 UTC via Fixed(1 day).
80        assert_eq!(
81            close_time_from_open(
82                dt("2024-01-31T00:00:00Z"),
83                IntervalStep::Fixed(Duration::days(1))
84            ),
85            Some(dt("2024-02-01T00:00:00Z"))
86        );
87    }
88
89    #[test]
90    fn months_step_uses_calendar_arithmetic() {
91        // Jan -> Feb (not +30 days = Jan 31).
92        assert_eq!(
93            close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(1)),
94            Some(dt("2024-02-01T00:00:00Z"))
95        );
96        // Leap-year Feb -> Mar (Feb has 29 days in 2024).
97        assert_eq!(
98            close_time_from_open(dt("2024-02-01T00:00:00Z"), IntervalStep::Months(1)),
99            Some(dt("2024-03-01T00:00:00Z"))
100        );
101        // Quarter = 3 months.
102        assert_eq!(
103            close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(3)),
104            Some(dt("2024-04-01T00:00:00Z"))
105        );
106        // Year = 12 months.
107        assert_eq!(
108            close_time_from_open(dt("2024-01-01T00:00:00Z"), IntervalStep::Months(12)),
109            Some(dt("2025-01-01T00:00:00Z"))
110        );
111    }
112
113    #[test]
114    fn months_step_clamps_jan_31_anchor() {
115        // Monthly bar opens always land on the 1st from all known producers, so
116        // this clamping is unreachable in practice; the test pins chrono's
117        // documented behaviour for the variable-length-month edge case.
118        // chrono clamps to the last valid day: Jan 31 + 1 month -> Feb 29 (leap year).
119        assert_eq!(
120            close_time_from_open(dt("2024-01-31T00:00:00Z"), IntervalStep::Months(1)),
121            Some(dt("2024-02-29T00:00:00Z"))
122        );
123    }
124
125    #[test]
126    fn overflow_returns_none_not_panic() {
127        let max = DateTime::<Utc>::MAX_UTC;
128        assert_eq!(close_time_from_open(max, IntervalStep::Months(1)), None);
129        assert_eq!(
130            close_time_from_open(max, IntervalStep::Fixed(Duration::days(1))),
131            None
132        );
133    }
134
135    #[test]
136    fn open_time_from_close_is_inverse() {
137        // open = close − interval, for both Fixed and Months steps.
138        assert_eq!(
139            open_time_from_close(
140                dt("2024-01-15T13:00:00Z"),
141                IntervalStep::Fixed(Duration::hours(1))
142            ),
143            Some(dt("2024-01-15T12:00:00Z"))
144        );
145        // Feb 1 close of a January monthly bar → Jan 1 open.
146        assert_eq!(
147            open_time_from_close(dt("2024-02-01T00:00:00Z"), IntervalStep::Months(1)),
148            Some(dt("2024-01-01T00:00:00Z"))
149        );
150        // Round-trip identity for the inputs this library actually produces:
151        // monthly/quarterly closes always land on a calendar 1st, where chrono's
152        // month arithmetic round-trips exactly. (It is NOT a universal identity —
153        // `Months` day-clamping is asymmetric for non-1st anchors, e.g.
154        // Feb 29 −1mo → Jan 29, +1mo → Feb 29; see `months_step_clamps_jan_31_anchor`.)
155        let close = dt("2024-04-01T00:00:00Z");
156        let open = open_time_from_close(close, IntervalStep::Months(3)).unwrap();
157        assert_eq!(
158            close_time_from_open(open, IntervalStep::Months(3)),
159            Some(close)
160        );
161    }
162
163    #[test]
164    fn open_time_from_close_underflow_returns_none() {
165        let min = DateTime::<Utc>::MIN_UTC;
166        assert_eq!(open_time_from_close(min, IntervalStep::Months(1)), None);
167        assert_eq!(
168            open_time_from_close(min, IntervalStep::Fixed(Duration::days(1))),
169            None
170        );
171    }
172
173    #[test]
174    fn candle_interval_all_covers_every_variant_in_ascending_order() {
175        // `ALL`'s length is pinned to the variant count by both the
176        // `[CandleInterval; 16]` type and this assertion. Full variant *coverage*
177        // is not compile-enforced (Rust has no stable variant_count), so keep
178        // `ALL` in sync when adding a variant.
179        assert_eq!(CandleInterval::ALL.len(), 16);
180
181        // Verify the documented ascending-duration ordering directly via `to_step`.
182        // Comparing against the derived `Ord` would be tautological — that order is
183        // declaration order, identical to `ALL`'s. Mapping through durations instead
184        // actually fails if a variant is listed out of order.
185        fn approx_secs(interval: CandleInterval) -> i64 {
186            match interval.to_step() {
187                IntervalStep::Fixed(d) => d.num_seconds(),
188                // Only `Month1` is calendar-based; ~30d keeps it above `Week1` (7d).
189                IntervalStep::Months(n) => i64::from(n) * 30 * 24 * 60 * 60,
190            }
191        }
192        for pair in CandleInterval::ALL.windows(2) {
193            assert!(
194                approx_secs(pair[0]) < approx_secs(pair[1]),
195                "ALL must be in strictly ascending duration order: {:?} !< {:?}",
196                pair[0],
197                pair[1]
198            );
199        }
200    }
201
202    #[test]
203    fn candle_interval_display_equals_as_str_for_every_variant() {
204        for interval in CandleInterval::ALL {
205            assert_eq!(interval.to_string(), interval.as_str());
206        }
207    }
208
209    #[test]
210    fn candle_interval_as_str_matches_binance_exactly() {
211        // Case-sensitive: `1M` (month) is the only uppercase form.
212        assert_eq!(CandleInterval::Sec1.as_str(), "1s");
213        assert_eq!(CandleInterval::Min1.as_str(), "1m");
214        assert_eq!(CandleInterval::Hour6.as_str(), "6h");
215        assert_eq!(CandleInterval::Month1.as_str(), "1M");
216    }
217
218    #[test]
219    fn candle_interval_from_str_is_inverse_of_as_str() {
220        for interval in CandleInterval::ALL {
221            assert_eq!(interval.as_str().parse::<CandleInterval>(), Ok(interval));
222        }
223    }
224
225    #[test]
226    fn candle_interval_from_str_rejects_garbage() {
227        assert!("".parse::<CandleInterval>().is_err());
228        assert!("7m".parse::<CandleInterval>().is_err());
229        // Case-sensitive: `1m` (minute) must not parse as `1M` (month) or vice versa.
230        assert_eq!("1m".parse::<CandleInterval>(), Ok(CandleInterval::Min1));
231        assert_eq!("1M".parse::<CandleInterval>(), Ok(CandleInterval::Month1));
232    }
233
234    #[test]
235    fn candle_interval_serde_round_trips_every_variant() {
236        for interval in CandleInterval::ALL {
237            let json = serde_json::to_string(&interval).unwrap();
238            // Serialises as the bare `as_str()` string.
239            assert_eq!(json, format!("\"{}\"", interval.as_str()));
240            let back: CandleInterval = serde_json::from_str(&json).unwrap();
241            assert_eq!(back, interval);
242        }
243    }
244
245    #[test]
246    fn candles_kind_carries_interval_and_serde_round_trips() {
247        let kind = Candles {
248            interval: CandleInterval::Hour6,
249        };
250        let json = serde_json::to_string(&kind).unwrap();
251        assert_eq!(json, r#"{"interval":"6h"}"#);
252        let back: Candles = serde_json::from_str(&json).unwrap();
253        assert_eq!(back, kind);
254        // `SubscriptionKind::as_str` stays the kind tag, independent of interval.
255        assert_eq!(kind.as_str(), "candles");
256        assert_eq!(kind.to_string(), "candles");
257    }
258}
259
260/// Normalised Barter OHLCV [`Candle`] model.
261///
262/// # `close_time` contract
263///
264/// `close_time` is the **exclusive end-of-period boundary** of the candle:
265///
266/// ```text
267/// close_time == open_time + interval
268/// ```
269///
270/// A candle aggregates the trades that fall in the **half-open interval**
271/// `[close_time − interval, close_time)` — i.e. trades with
272/// `open_time ≤ ts < close_time`. A trade landing exactly on `close_time`
273/// belongs to the **next** candle, so `close_time` equals the next candle's
274/// open instant.
275///
276/// Two distinct caveats apply to the boundary — do not conflate them:
277///
278/// - **Not session-aligned** (daily/weekly/monthly): the boundary is the UTC
279///   period grid (`day → next 00:00 UTC`, etc.), **not** an exchange session
280///   close. The library has no session calendar.
281/// - **Variable-length calendar arithmetic** (month/quarter/year only): these
282///   are nominal boundaries computed with calendar months (chrono [`Months`]),
283///   not fixed [`Duration`]s. Daily and weekly are exact fixed durations in UTC
284///   (no DST), exact to the millisecond. The `open_time + N months` equality
285///   holds only when `open_time` is calendar-grid-aligned (e.g. a `1M` candle
286///   opens on the 1st at 00:00 UTC); a non-aligned open day-clamps
287///   (Jan 31 + 1 month → Feb 28/29). Venue-supplied open times are always
288///   aligned, so clamping never arises in practice, but it is part of the
289///   contract for callers computing boundaries from arbitrary instants.
290///
291/// `Candle` deliberately carries **neither `open_time` nor `interval`** — recover
292/// them from the originating fetch request / subscription resolution
293/// (`open_time ≡ close_time − interval`). Range-computing producers derive
294/// `close_time` through [`close_time_from_open`] so the boundary is defined in
295/// exactly one place (the Massive WS path uses the venue-supplied boundary
296/// directly — see [`close_time_from_open`] for the full producer list).
297///
298/// # Using a `Candle` with the engine
299///
300/// When wrapping a `Candle` into a [`MarketEvent`](crate::event::MarketEvent) for
301/// a consuming engine (live or backtest), set
302/// [`time_exchange`](crate::event::MarketEvent::time_exchange) to this
303/// `close_time` — it is the period-END instant, the only choice that avoids
304/// lookahead (see that field's contract). The library's own candle producers
305/// already do this.
306///
307/// [`Months`]: chrono::Months
308/// [`Duration`]: chrono::Duration
309#[derive(Copy, Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
310pub struct Candle {
311    pub close_time: DateTime<Utc>,
312    pub open: Decimal,
313    pub high: Decimal,
314    pub low: Decimal,
315    pub close: Decimal,
316    pub volume: Decimal,
317    pub trade_count: u64,
318}
319
320/// One step from a candle's open instant to its exclusive close boundary.
321///
322/// Keyed on a primitive step type (not on any per-exchange interval enum) so
323/// every producer — regardless of how it names its native intervals — maps to
324/// the same two cases and routes through [`close_time_from_open`]. This is the
325/// mechanism that makes the [`Candle::close_time`] contract *enforced by
326/// construction* rather than merely documented.
327#[derive(Copy, Clone, Eq, PartialEq, Debug)]
328pub enum IntervalStep {
329    /// A fixed-length step (seconds through weeks — exact in UTC, no DST).
330    Fixed(chrono::Duration),
331    /// A variable-length calendar step in whole months. Covers calendar
332    /// `month` (1), `quarter` (3) and `year` (12) — leap-year-correct via
333    /// chrono's [`checked_add_months`](DateTime::checked_add_months).
334    Months(u32),
335}
336
337/// Compute a candle's exclusive `close_time` boundary from its `open` instant.
338///
339/// This is the single shared boundary helper that every range-computing
340/// [`Candle`] producer routes through (Massive REST, Hyperliquid, IBKR), so the
341/// `close_time == open + interval` contract is computed in exactly one place. The
342/// Massive WS path is the lone exception: it trusts the venue-supplied boundary
343/// directly (see `WsAggregateMsg::into_candle` for the rationale).
344///
345/// - [`IntervalStep::Fixed`] adds a [`chrono::Duration`].
346/// - [`IntervalStep::Months`] uses calendar-correct month arithmetic
347///   ([`checked_add_months`](DateTime::checked_add_months)), so a Jan monthly
348///   bar yields `Feb 1 00:00 UTC` and a leap-year Feb monthly bar yields
349///   `Mar 1 00:00 UTC`.
350///
351/// # Returns
352///
353/// `None` on overflow — when the computed boundary falls outside the
354/// representable [`DateTime<Utc>`] range. Callers **must** surface this as their
355/// producer error type (an observable failure), **never** a silent fallback to a
356/// plausible-but-wrong timestamp such as `UNIX_EPOCH`.
357#[must_use]
358pub fn close_time_from_open(open: DateTime<Utc>, step: IntervalStep) -> Option<DateTime<Utc>> {
359    match step {
360        IntervalStep::Fixed(duration) => open.checked_add_signed(duration),
361        IntervalStep::Months(n) => open.checked_add_months(chrono::Months::new(n)),
362    }
363}
364
365/// Inverse of [`close_time_from_open`]: recover a candle's `open` instant from its
366/// exclusive `close_time` boundary (`open == close − interval`).
367///
368/// Used by range-bounded historical fetches to widen the venue request window:
369/// the candle whose `close_time == start` has `open == start − interval`, so a
370/// fetch that wants `close_time ∈ [start, end]` must ask the venue for opens down
371/// to `start − interval` (then trim the result by `close_time`). See
372/// [`Candle::close_time`].
373///
374/// # Returns
375///
376/// `None` on underflow (the computed open falls below the representable
377/// [`DateTime<Utc>`] range).
378///
379/// For the range-widening use-case this `None` is **not** an error: it means the
380/// candle whose `close_time == start` would have an unrepresentable open
381/// (`start − interval` below [`DateTime<Utc>`] minimum) and therefore cannot
382/// exist. Callers should fall back to the original lower bound — the un-widened
383/// fetch already yields the complete, correct result set, so this is the right
384/// outcome rather than a silent failure. (Contrast [`close_time_from_open`],
385/// whose `None` *does* signal data loss for a real candle and must be surfaced
386/// as an error.)
387#[must_use]
388pub fn open_time_from_close(close: DateTime<Utc>, step: IntervalStep) -> Option<DateTime<Utc>> {
389    match step {
390        IntervalStep::Fixed(duration) => close.checked_sub_signed(duration),
391        IntervalStep::Months(n) => close.checked_sub_months(chrono::Months::new(n)),
392    }
393}
394
395/// Candle interval/resolution, shared across every venue that produces candles.
396///
397/// This is the **venue-agnostic union** of all supported candle resolutions — it
398/// is deliberately *not* gated to any one exchange's capabilities. Per-venue
399/// support rules (e.g. Hyperliquid rejecting `Sec1`/`Hour6`) live in the exchange
400/// layer, not on this enum (separation of concerns). Because the enum is a union,
401/// **each venue's interval guard must be re-reviewed whenever a variant is added.**
402///
403/// # String form
404///
405/// [`as_str`](Self::as_str) is the **single source of truth** for every string
406/// representation: [`Display`](std::fmt::Display), [`Serialize`], [`FromStr`] and [`Deserialize`] all
407/// delegate to it (or its inverse), so there is exactly one place mapping
408/// variant↔string. The strings match Binance's kline `interval` parameter exactly
409/// and are **case-sensitive** — note `Month1 → "1M"` (uppercase) vs `Min1 → "1m"`.
410///
411/// # Ordering
412///
413/// Variants are declared in **ascending duration** order. `Ord`/`PartialOrd` exist
414/// only as a compile requirement (the type embeds in
415/// [`Candles`], which must stay `Ord` to
416/// preserve the derived `Ord` on `Subscription`); the chronological declaration
417/// order makes the derived order at least sensible. Nothing currently sorts
418/// intervals semantically.
419#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Ord, PartialOrd)]
420pub enum CandleInterval {
421    /// 1 second
422    Sec1,
423    /// 1 minute
424    Min1,
425    /// 3 minutes
426    Min3,
427    /// 5 minutes
428    Min5,
429    /// 15 minutes
430    Min15,
431    /// 30 minutes
432    Min30,
433    /// 1 hour
434    Hour1,
435    /// 2 hours
436    Hour2,
437    /// 4 hours
438    Hour4,
439    /// 6 hours
440    Hour6,
441    /// 8 hours
442    Hour8,
443    /// 12 hours
444    Hour12,
445    /// 1 day
446    Day1,
447    /// 3 days
448    Day3,
449    /// 1 week
450    Week1,
451    /// 1 month
452    Month1,
453}
454
455impl CandleInterval {
456    /// Every [`CandleInterval`] variant, in ascending-duration declaration order.
457    ///
458    /// Lets variant-exhaustive tests (round-trip, channel-suffix drift guards)
459    /// iterate without hand-listing the variants.
460    ///
461    /// When adding a [`CandleInterval`] variant, add it here too: the length
462    /// literal and the `candle_interval_all_covers_every_variant_in_ascending_order`
463    /// test pin `ALL`'s length to the variant count, but full coverage is not
464    /// compile-enforced — the exhaustive `match`es elsewhere are the compile gate.
465    pub const ALL: [CandleInterval; 16] = [
466        Self::Sec1,
467        Self::Min1,
468        Self::Min3,
469        Self::Min5,
470        Self::Min15,
471        Self::Min30,
472        Self::Hour1,
473        Self::Hour2,
474        Self::Hour4,
475        Self::Hour6,
476        Self::Hour8,
477        Self::Hour12,
478        Self::Day1,
479        Self::Day3,
480        Self::Week1,
481        Self::Month1,
482    ];
483
484    /// The exchange string form of this interval (e.g. `"1m"`, `"6h"`, `"1M"`).
485    ///
486    /// The **single source of truth** for all string representations — `Display`,
487    /// `Serialize`, `FromStr` and `Deserialize` all key off this. Case-sensitive:
488    /// `Month1 → "1M"`, every other variant lowercase.
489    #[must_use]
490    pub fn as_str(&self) -> &'static str {
491        match self {
492            Self::Sec1 => "1s",
493            Self::Min1 => "1m",
494            Self::Min3 => "3m",
495            Self::Min5 => "5m",
496            Self::Min15 => "15m",
497            Self::Min30 => "30m",
498            Self::Hour1 => "1h",
499            Self::Hour2 => "2h",
500            Self::Hour4 => "4h",
501            Self::Hour6 => "6h",
502            Self::Hour8 => "8h",
503            Self::Hour12 => "12h",
504            Self::Day1 => "1d",
505            Self::Day3 => "3d",
506            Self::Week1 => "1w",
507            Self::Month1 => "1M",
508        }
509    }
510
511    /// Map this interval to the shared [`IntervalStep`] used to compute a candle's
512    /// exclusive `close_time` boundary via [`close_time_from_open`]. All intervals
513    /// are fixed-length except `1M`, which is a calendar month.
514    #[must_use]
515    pub fn to_step(self) -> IntervalStep {
516        match self {
517            Self::Sec1 => IntervalStep::Fixed(Duration::seconds(1)),
518            Self::Min1 => IntervalStep::Fixed(Duration::minutes(1)),
519            Self::Min3 => IntervalStep::Fixed(Duration::minutes(3)),
520            Self::Min5 => IntervalStep::Fixed(Duration::minutes(5)),
521            Self::Min15 => IntervalStep::Fixed(Duration::minutes(15)),
522            Self::Min30 => IntervalStep::Fixed(Duration::minutes(30)),
523            Self::Hour1 => IntervalStep::Fixed(Duration::hours(1)),
524            Self::Hour2 => IntervalStep::Fixed(Duration::hours(2)),
525            Self::Hour4 => IntervalStep::Fixed(Duration::hours(4)),
526            Self::Hour6 => IntervalStep::Fixed(Duration::hours(6)),
527            Self::Hour8 => IntervalStep::Fixed(Duration::hours(8)),
528            Self::Hour12 => IntervalStep::Fixed(Duration::hours(12)),
529            Self::Day1 => IntervalStep::Fixed(Duration::days(1)),
530            Self::Day3 => IntervalStep::Fixed(Duration::days(3)),
531            Self::Week1 => IntervalStep::Fixed(Duration::weeks(1)),
532            Self::Month1 => IntervalStep::Months(1),
533        }
534    }
535}
536
537impl std::fmt::Display for CandleInterval {
538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539        f.write_str(self.as_str())
540    }
541}
542
543/// Error returned by [`CandleInterval::from_str`] for an unrecognised string.
544///
545/// The offending input is kept private and exposed via [`input`](Self::input) so
546/// the error's representation can evolve (e.g. gaining context) without a
547/// breaking change — mirroring `std`'s opaque parse-error types.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct ParseCandleIntervalError {
550    invalid: String,
551}
552
553impl ParseCandleIntervalError {
554    /// The input string that failed to parse.
555    #[must_use]
556    pub fn input(&self) -> &str {
557        &self.invalid
558    }
559}
560
561impl std::fmt::Display for ParseCandleIntervalError {
562    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563        write!(f, "invalid candle interval: {:?}", self.invalid)
564    }
565}
566
567impl std::error::Error for ParseCandleIntervalError {}
568
569impl FromStr for CandleInterval {
570    type Err = ParseCandleIntervalError;
571
572    /// The inverse of [`CandleInterval::as_str`] — case-sensitive (`"1M"` is the
573    /// only uppercase form). Keeps variant↔string mapping in exactly one place.
574    fn from_str(s: &str) -> Result<Self, Self::Err> {
575        match s {
576            "1s" => Ok(Self::Sec1),
577            "1m" => Ok(Self::Min1),
578            "3m" => Ok(Self::Min3),
579            "5m" => Ok(Self::Min5),
580            "15m" => Ok(Self::Min15),
581            "30m" => Ok(Self::Min30),
582            "1h" => Ok(Self::Hour1),
583            "2h" => Ok(Self::Hour2),
584            "4h" => Ok(Self::Hour4),
585            "6h" => Ok(Self::Hour6),
586            "8h" => Ok(Self::Hour8),
587            "12h" => Ok(Self::Hour12),
588            "1d" => Ok(Self::Day1),
589            "3d" => Ok(Self::Day3),
590            "1w" => Ok(Self::Week1),
591            "1M" => Ok(Self::Month1),
592            other => Err(ParseCandleIntervalError {
593                invalid: other.to_owned(),
594            }),
595        }
596    }
597}
598
599impl Serialize for CandleInterval {
600    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
601    where
602        S: Serializer,
603    {
604        serializer.serialize_str(self.as_str())
605    }
606}
607
608impl<'de> Deserialize<'de> for CandleInterval {
609    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
610    where
611        D: Deserializer<'de>,
612    {
613        let raw = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
614        raw.parse().map_err(de::Error::custom)
615    }
616}