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}