Skip to main content

egui_charts/model/
timeframe.rs

1//! Timeframe (bar aggregation interval) for financial charts.
2//!
3//! A [`Timeframe`] specifies the time window or execution-based threshold represented by a bar.  The library
4//! ships with standard presets from 100 ms up to 12 months, and a [`Custom`](Timeframe::Custom)
5//! variant for arbitrary intervals.
6//!
7//! # Parsing
8//!
9//! Timeframes can be parsed from human-readable strings via [`FromStr`]:
10//!
11//! ```
12//! use egui_charts::model::Timeframe;
13//!
14//! let tf: Timeframe = "15min".parse().unwrap();
15//! assert_eq!(tf, Timeframe::Min15);
16//!
17//! let custom: Timeframe = "45s".parse().unwrap();
18//! assert!(custom.is_custom());
19//! ```
20
21use chrono::Duration;
22use serde::{Deserialize, Serialize};
23use std::borrow::Cow;
24use std::fmt;
25use std::num::{NonZeroU32, NonZeroU64};
26use std::str::FromStr;
27
28/// Timeframe for bar aggregation.
29///
30/// Time variants represent a fixed or calendar interval over which OHLCV data is aggregated
31/// into a single [`Bar`](super::Bar). The default timeframe is [`Min1`](Timeframe::Min1).
32///
33/// Timeframes are divided into tiers:
34///
35/// | Tier         | Variants                                               |
36/// |-------------|-------------------------------------------------------|
37/// | Tick count  | `Ticks(count)` -- a nonzero number of executions         |
38/// | Volume      | `Volume(units)` -- exact traded volume                   |
39/// | Range       | `Range(ticks)` -- price range in instrument ticks        |
40/// | Delta       | `Delta(units)` -- absolute buy minus sell volume         |
41/// | Millisecond | `Ms100`, `Ms250`, `Ms500`                              |
42/// | Second      | `Sec1` .. `Sec30`                                      |
43/// | Minute      | `Min1`, `Min5`, `Min15`, `Min30`                       |
44/// | Hourly      | `Hour1`, `Hour4`                                       |
45/// | Daily+      | `Day1`, `Week1`, `Month1`, `Weeks(count)`, `Months(count)` |
46/// | Custom      | `Custom(seconds)` -- any user-defined interval          |
47///
48/// # Example
49///
50/// ```
51/// use egui_charts::model::Timeframe;
52///
53/// let tf = Timeframe::Hour4;
54/// assert_eq!(tf.duration_ms(), 14_400_000);
55/// assert_eq!(tf.to_string(), "4h");
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[non_exhaustive]
59pub enum Timeframe {
60    // Millisecond timeframes (for high-frequency trading)
61    /// 100-millisecond bars (HFT).
62    Ms100,
63    /// 250-millisecond bars (HFT).
64    Ms250,
65    /// 500-millisecond bars (HFT).
66    Ms500,
67
68    // Second timeframes
69    /// 1-second bars.
70    Sec1,
71    /// 2-second bars.
72    Sec2,
73    /// 5-second bars.
74    Sec5,
75    /// 10-second bars.
76    Sec10,
77    /// 30-second bars.
78    Sec30,
79
80    // Minute timeframes
81    /// 1-minute bars (default).
82    Min1,
83    /// 5-minute bars.
84    Min5,
85    /// 15-minute bars.
86    Min15,
87    /// 30-minute bars.
88    Min30,
89
90    // Hourly timeframes
91    /// 1-hour bars.
92    Hour1,
93    /// 4-hour bars.
94    Hour4,
95
96    // Daily and longer timeframes
97    /// Daily bars.
98    Day1,
99    /// Weekly bars.
100    Week1,
101    /// Monthly bars (approximated as 30 days for duration calculations).
102    Month1,
103
104    /// Custom timeframe defined in seconds.
105    /// Allows user-defined intervals (e.g., 45s, 3min, 2h). The interval is never zero.
106    Custom(NonZeroU64),
107
108    /// One bar per nonzero number of accepted trades, starting at subscription or replay start.
109    Ticks(NonZeroU64),
110    /// Exact traded volume per bar, in whole units. A fill spanning bars is split by size.
111    Volume(NonZeroU64),
112    /// Price range in instrument ticks. Closes on the first real fill reaching the range;
113    /// skipped prices can overshoot it. No synthetic executions are inserted.
114    Range(NonZeroU64),
115    /// Absolute buy minus sell volume per bar, in whole units. Fills split at the threshold;
116    /// unknown sides contribute volume but no delta.
117    Delta(NonZeroU64),
118
119    /// Calendar weeks, aligned to Monday 1970-01-05 in the clock's timezone.
120    Weeks(NonZeroU32),
121    /// Calendar months, aligned to January 1970 in the clock's timezone.
122    /// Three, six and twelve months start on calendar quarters, half-years and years.
123    Months(NonZeroU32),
124}
125
126impl Timeframe {
127    /// Returns the canonical string label for this timeframe (e.g. `"1min"`, `"4h"`, `"1D"`).
128    pub fn as_str(&self) -> Cow<'static, str> {
129        match self {
130            Timeframe::Weeks(count) => Cow::Owned(format!("{count}W")),
131            Timeframe::Months(count) => Cow::Owned(format!("{count}M")),
132            Timeframe::Ticks(count) => Cow::Owned(format!("{count}t")),
133            Timeframe::Volume(size) => Cow::Owned(format!("{size}v")),
134            Timeframe::Range(ticks) => Cow::Owned(format!("{ticks}r")),
135            Timeframe::Delta(size) => Cow::Owned(format!("{size}delta")),
136            Timeframe::Ms100 => Cow::Borrowed("100ms"),
137            Timeframe::Ms250 => Cow::Borrowed("250ms"),
138            Timeframe::Ms500 => Cow::Borrowed("500ms"),
139            Timeframe::Sec1 => Cow::Borrowed("1s"),
140            Timeframe::Sec2 => Cow::Borrowed("2s"),
141            Timeframe::Sec5 => Cow::Borrowed("5s"),
142            Timeframe::Sec10 => Cow::Borrowed("10s"),
143            Timeframe::Sec30 => Cow::Borrowed("30s"),
144            Timeframe::Min1 => Cow::Borrowed("1min"),
145            Timeframe::Min5 => Cow::Borrowed("5min"),
146            Timeframe::Min15 => Cow::Borrowed("15min"),
147            Timeframe::Min30 => Cow::Borrowed("30min"),
148            Timeframe::Hour1 => Cow::Borrowed("1h"),
149            Timeframe::Hour4 => Cow::Borrowed("4h"),
150            Timeframe::Day1 => Cow::Borrowed("1D"),
151            Timeframe::Week1 => Cow::Borrowed("1W"),
152            Timeframe::Month1 => Cow::Borrowed("1M"),
153            Timeframe::Custom(seconds) => Cow::Owned(format_custom_seconds(seconds.get())),
154        }
155    }
156
157    /// Whether bars close on executions rather than a time boundary.
158    pub fn is_trade_based(self) -> bool {
159        matches!(
160            self,
161            Self::Ticks(_) | Self::Volume(_) | Self::Range(_) | Self::Delta(_)
162        )
163    }
164
165    /// Returns the preset time intervals in ascending duration order. Trade-based intervals are excluded.
166    pub fn all() -> Vec<Timeframe> {
167        vec![
168            Timeframe::Ms100,
169            Timeframe::Ms250,
170            Timeframe::Ms500,
171            Timeframe::Sec1,
172            Timeframe::Sec2,
173            Timeframe::Sec5,
174            Timeframe::Sec10,
175            Timeframe::Sec30,
176            Timeframe::Min1,
177            Timeframe::Min5,
178            Timeframe::Min15,
179            Timeframe::Min30,
180            Timeframe::Hour1,
181            Timeframe::Hour4,
182            Timeframe::Day1,
183            Timeframe::Week1,
184            Timeframe::Month1,
185            Timeframe::Months(NonZeroU32::new(3).unwrap()),
186            Timeframe::Months(NonZeroU32::new(6).unwrap()),
187            Timeframe::Months(NonZeroU32::new(12).unwrap()),
188        ]
189    }
190
191    /// Duration in milliseconds, saturating at `i64::MAX` for huge custom timeframes.
192    /// Calendar months use an approximate 30 days each; use [`super::BarClock`] for boundaries.
193    /// Trade-based intervals have no time duration and return zero; do not use them to step time.
194    pub fn duration_ms(&self) -> i64 {
195        match self {
196            Timeframe::Ticks(_)
197            | Timeframe::Volume(_)
198            | Timeframe::Range(_)
199            | Timeframe::Delta(_) => 0,
200            Timeframe::Ms100 => 100,
201            Timeframe::Ms250 => 250,
202            Timeframe::Ms500 => 500,
203            Timeframe::Sec1 => 1000,
204            Timeframe::Sec2 => 2000,
205            Timeframe::Sec5 => 5000,
206            Timeframe::Sec10 => 10000,
207            Timeframe::Sec30 => 30000,
208            Timeframe::Min1 => 60_000,
209            Timeframe::Min5 => 300_000,
210            Timeframe::Min15 => 900_000,
211            Timeframe::Min30 => 1_800_000,
212            Timeframe::Hour1 => 3_600_000,
213            Timeframe::Hour4 => 14_400_000,
214            Timeframe::Day1 => 86_400_000,
215            Timeframe::Week1 => 604_800_000,
216            Timeframe::Month1 => 2_592_000_000, // Approximation: 30 days
217            Timeframe::Weeks(count) => i64::from(count.get()) * 604_800_000,
218            Timeframe::Months(count) => i64::from(count.get()).saturating_mul(2_592_000_000),
219            Timeframe::Custom(seconds) => {
220                seconds.get().saturating_mul(1000).min(i64::MAX as u64) as i64
221            }
222        }
223    }
224
225    /// Duration as a chrono value; zero for trade-based intervals.
226    pub fn duration(&self) -> Duration {
227        Duration::milliseconds(self.duration_ms())
228    }
229
230    /// Returns true if this is a user-defined custom timeframe
231    pub fn is_custom(&self) -> bool {
232        match self {
233            Timeframe::Months(count) => !matches!(count.get(), 3 | 6 | 12),
234            _ => matches!(self, Timeframe::Custom(_) | Timeframe::Weeks(_)),
235        }
236    }
237
238    /// Convert a resolution string to Timeframe
239    pub fn from_resolution(resolution: &str) -> Option<Self> {
240        if let Ok(timeframe) = resolution.parse::<Self>()
241            && (timeframe.is_trade_based() || matches!(timeframe, Self::Weeks(_) | Self::Months(_)))
242        {
243            return Some(timeframe);
244        }
245        match resolution {
246            "1" => Some(Timeframe::Min1),
247            "5" => Some(Timeframe::Min5),
248            "15" => Some(Timeframe::Min15),
249            "30" => Some(Timeframe::Min30),
250            "60" | "1H" => Some(Timeframe::Hour1),
251            "240" | "4H" => Some(Timeframe::Hour4),
252            "1D" | "D" => Some(Timeframe::Day1),
253            "1W" | "W" => Some(Timeframe::Week1),
254            "1M" | "M" => Some(Timeframe::Month1),
255            _ => None,
256        }
257    }
258}
259
260/// Unit of an interval, as its label names it: `90min` is 90 [`Minute`](Self::Minute)s and
261/// `1D` one [`Day`](Self::Day). Units are ordered as declared, trade-based units first.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
263#[non_exhaustive]
264pub enum TimeframeUnit {
265    /// A fixed number of executions per bar.
266    Tick,
267    /// Traded volume in whole units, split exactly at the threshold.
268    Volume,
269    /// Price range in instrument ticks.
270    Range,
271    /// Absolute buy minus sell volume in whole units.
272    Delta,
273    /// One of the model's subsecond presets.
274    Millisecond,
275    /// Seconds.
276    Second,
277    /// Minutes.
278    Minute,
279    /// Hours.
280    Hour,
281    /// Days.
282    Day,
283    /// Weeks.
284    Week,
285    /// Months.
286    Month,
287}
288
289impl TimeframeUnit {
290    /// Abbreviated unit label.
291    pub fn short_name(&self) -> &'static str {
292        match self {
293            TimeframeUnit::Tick => "T",
294            TimeframeUnit::Volume => "V",
295            TimeframeUnit::Range => "R",
296            TimeframeUnit::Delta => "delta",
297            TimeframeUnit::Millisecond => "ms",
298            TimeframeUnit::Second => "S",
299            TimeframeUnit::Minute => "m",
300            TimeframeUnit::Hour => "h",
301            TimeframeUnit::Day => "D",
302            TimeframeUnit::Week => "W",
303            TimeframeUnit::Month => "M",
304        }
305    }
306
307    /// Display name for this value.
308    pub fn name(&self) -> &'static str {
309        match self {
310            TimeframeUnit::Tick => "ticks",
311            TimeframeUnit::Volume => "volume units",
312            TimeframeUnit::Range => "range ticks",
313            TimeframeUnit::Delta => "delta units",
314            TimeframeUnit::Millisecond => "milliseconds",
315            TimeframeUnit::Second => "seconds",
316            TimeframeUnit::Minute => "minutes",
317            TimeframeUnit::Hour => "hours",
318            TimeframeUnit::Day => "days",
319            TimeframeUnit::Week => "weeks",
320            TimeframeUnit::Month => "months",
321        }
322    }
323}
324
325/// Format a custom seconds value into a human-readable label.
326/// Chooses the largest clean unit: days > hours > minutes > seconds.
327fn format_custom_seconds(seconds: u64) -> String {
328    if seconds >= 86400 && seconds.is_multiple_of(86400) {
329        format!("{}D", seconds / 86400)
330    } else if seconds >= 3600 && seconds.is_multiple_of(3600) {
331        format!("{}h", seconds / 3600)
332    } else if seconds >= 60 && seconds.is_multiple_of(60) {
333        format!("{}min", seconds / 60)
334    } else {
335        format!("{seconds}s")
336    }
337}
338
339impl Default for Timeframe {
340    /// Default timeframe is 1 minute
341    fn default() -> Self {
342        Timeframe::Min1
343    }
344}
345
346impl fmt::Display for Timeframe {
347    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
348        write!(f, "{}", self.as_str())
349    }
350}
351
352impl FromStr for Timeframe {
353    type Err = super::ParseError;
354
355    fn from_str(s: &str) -> Result<Self, Self::Err> {
356        for (suffix, make) in [
357            ("delta", Timeframe::Delta as fn(NonZeroU64) -> Timeframe),
358            ("v", Timeframe::Volume),
359            ("r", Timeframe::Range),
360        ] {
361            if let Some(value) = s.to_lowercase().strip_suffix(suffix)
362                && value.chars().all(|c| c.is_ascii_digit())
363            {
364                return value.parse::<NonZeroU64>().map(make).map_err(|_| {
365                    super::ParseError::new(
366                        "timeframe",
367                        s,
368                        "trade interval must be a positive integer",
369                    )
370                });
371            }
372        }
373        if let Some(count) = s.to_lowercase().strip_suffix('t') {
374            return count
375                .parse::<NonZeroU64>()
376                .map(Timeframe::Ticks)
377                .map_err(|_| {
378                    super::ParseError::new("timeframe", s, "tick count must be a positive integer")
379                });
380        }
381        // Try known preset names first
382        match s.to_lowercase().as_str() {
383            "100ms" => return Ok(Timeframe::Ms100),
384            "250ms" => return Ok(Timeframe::Ms250),
385            "500ms" => return Ok(Timeframe::Ms500),
386            "1s" | "1sec" => return Ok(Timeframe::Sec1),
387            "2s" | "2sec" => return Ok(Timeframe::Sec2),
388            "5s" | "5sec" => return Ok(Timeframe::Sec5),
389            "10s" | "10sec" => return Ok(Timeframe::Sec10),
390            "30s" | "30sec" => return Ok(Timeframe::Sec30),
391            "1min" => return Ok(Timeframe::Min1),
392            "5min" => return Ok(Timeframe::Min5),
393            "15min" => return Ok(Timeframe::Min15),
394            "30min" => return Ok(Timeframe::Min30),
395            "1h" | "1hour" => return Ok(Timeframe::Hour1),
396            "4h" | "4hour" => return Ok(Timeframe::Hour4),
397            "1d" | "1day" => return Ok(Timeframe::Day1),
398            "1w" | "1week" => return Ok(Timeframe::Week1),
399            "1m" | "1month" => return Ok(Timeframe::Month1),
400            _ => {}
401        }
402
403        for (suffix, make) in [
404            ("month", Timeframe::Months as fn(NonZeroU32) -> Timeframe),
405            ("m", Timeframe::Months),
406            ("week", Timeframe::Weeks),
407            ("w", Timeframe::Weeks),
408        ] {
409            if let Some(value) = s.to_lowercase().strip_suffix(suffix) {
410                return value.trim().parse::<NonZeroU32>().map(make).map_err(|_| {
411                    super::ParseError::new(
412                        "timeframe",
413                        s,
414                        "calendar interval must be a positive 32-bit integer",
415                    )
416                });
417            }
418        }
419
420        // Try parsing as custom timeframe: "<number><unit>"
421        let lower = s.to_lowercase();
422        if let Some(seconds) = parse_custom_timeframe(&lower) {
423            return NonZeroU64::new(seconds)
424                .map(Timeframe::Custom)
425                .ok_or_else(|| {
426                    super::ParseError::new(
427                        "timeframe",
428                        s,
429                        "custom timeframe must be greater than zero",
430                    )
431                });
432        }
433
434        Err(super::ParseError::new(
435            "timeframe",
436            s,
437            "valid formats are 100ms, 250ms, 500ms, 1s-30s, 1min-30min, 1h, 4h, 1D, 1W, 1M, or custom (e.g. 45s, 3min, 2h)",
438        ))
439    }
440}
441
442/// Parse a custom timeframe string like "45s", "3min", "2h", "2d"
443/// and return the total number of seconds.
444///
445/// Note: "m" suffix is intentionally excluded to avoid ambiguity with months.
446/// Use "min" for minutes (e.g. "3min", "45min").
447fn parse_custom_timeframe(s: &str) -> Option<u64> {
448    let suffixes: &[(&str, u64)] = &[
449        ("hour", 3600),
450        ("min", 60),
451        ("sec", 1),
452        ("day", 86400),
453        ("s", 1),
454        ("h", 3600),
455        ("d", 86400),
456    ];
457
458    for &(suffix, multiplier) in suffixes {
459        if let Some(num_str) = s.strip_suffix(suffix) {
460            let num: u64 = num_str.trim().parse().ok()?;
461            return num.checked_mul(multiplier);
462        }
463    }
464
465    // Try bare number as minutes (common convention)
466    let num: u64 = s.trim().parse().ok()?;
467    num.checked_mul(60)
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    fn custom(seconds: u64) -> Timeframe {
475        Timeframe::Custom(NonZeroU64::new(seconds).unwrap())
476    }
477
478    #[test]
479    fn test_preset_as_str() {
480        assert_eq!(&*Timeframe::Min1.as_str(), "1min");
481        assert_eq!(&*Timeframe::Hour4.as_str(), "4h");
482        assert_eq!(&*Timeframe::Day1.as_str(), "1D");
483    }
484
485    #[test]
486    fn test_custom_as_str() {
487        assert_eq!(&*custom(45).as_str(), "45s");
488        assert_eq!(&*custom(180).as_str(), "3min");
489        assert_eq!(&*custom(7200).as_str(), "2h");
490        assert_eq!(&*custom(172800).as_str(), "2D");
491    }
492
493    #[test]
494    fn test_custom_duration_ms() {
495        assert_eq!(custom(45).duration_ms(), 45_000);
496        assert_eq!(custom(180).duration_ms(), 180_000);
497        assert_eq!(custom(7200).duration_ms(), 7_200_000);
498        // Too large for i64 milliseconds: saturate rather than overflow or wrap negative.
499        assert_eq!(custom(10_000_000_000_000_000).duration_ms(), i64::MAX);
500        assert_eq!(
501            custom(u64::MAX).duration(),
502            Duration::milliseconds(i64::MAX)
503        );
504        assert!("40000000000000w".parse::<Timeframe>().is_err());
505    }
506
507    #[test]
508    fn test_is_custom() {
509        assert!(!Timeframe::Min1.is_custom());
510        assert!(!Timeframe::Day1.is_custom());
511        assert!(custom(45).is_custom());
512        assert!(custom(180).is_custom());
513        for (text, expected) in [("2w", true), ("2month", true), ("3month", false)] {
514            assert_eq!(text.parse::<Timeframe>().unwrap().is_custom(), expected);
515        }
516    }
517
518    #[test]
519    fn test_from_str_presets() {
520        assert_eq!("1s".parse::<Timeframe>(), Ok(Timeframe::Sec1));
521        assert_eq!("5min".parse::<Timeframe>(), Ok(Timeframe::Min5));
522        assert_eq!("1min".parse::<Timeframe>(), Ok(Timeframe::Min1));
523        let ticks = Timeframe::Ticks(NonZeroU64::new(10).unwrap());
524        assert_eq!("10t".parse::<Timeframe>(), Ok(ticks));
525        assert_eq!(ticks.to_string(), "10t");
526        assert_eq!(Timeframe::from_resolution("10T"), Some(ticks));
527        for text in ["100v", "10r", "100delta"] {
528            let timeframe: Timeframe = text.parse().unwrap();
529            assert_eq!(timeframe.to_string(), text);
530            assert_eq!(
531                Timeframe::from_resolution(&text.to_uppercase()),
532                Some(timeframe)
533            );
534        }
535        assert!("0t".parse::<Timeframe>().is_err());
536        assert!(serde_json::from_str::<Timeframe>(r#"{"Ticks":0}"#).is_err());
537        assert_eq!("1h".parse::<Timeframe>(), Ok(Timeframe::Hour1));
538        assert_eq!("1d".parse::<Timeframe>(), Ok(Timeframe::Day1));
539        assert_eq!("1w".parse::<Timeframe>(), Ok(Timeframe::Week1));
540        assert_eq!("1m".parse::<Timeframe>(), Ok(Timeframe::Month1));
541    }
542
543    #[test]
544    fn test_from_str_custom() {
545        assert_eq!("45s".parse::<Timeframe>(), Ok(custom(45)));
546        assert_eq!("45sec".parse::<Timeframe>(), Ok(custom(45)));
547        assert_eq!("3min".parse::<Timeframe>(), Ok(custom(180)));
548        assert_eq!("2h".parse::<Timeframe>(), Ok(custom(7200)));
549        assert_eq!("2hour".parse::<Timeframe>(), Ok(custom(7200)));
550        assert_eq!("2d".parse::<Timeframe>(), Ok(custom(172800)));
551        assert_eq!("2day".parse::<Timeframe>(), Ok(custom(172800)));
552        for (text, label, saved) in [
553            ("2w", "2W", r#"{"Weeks":2}"#),
554            ("3month", "3M", r#"{"Months":3}"#),
555        ] {
556            let timeframe: Timeframe = text.parse().unwrap();
557            assert_eq!(serde_json::to_string(&timeframe).unwrap(), saved);
558            assert_eq!(timeframe.to_string(), label);
559            assert_eq!(label.parse::<Timeframe>(), Ok(timeframe));
560            assert_eq!(Timeframe::from_resolution(label), Some(timeframe));
561            assert_eq!(serde_json::from_str::<Timeframe>(saved).unwrap(), timeframe);
562        }
563        for text in [
564            "0s",
565            "0min",
566            "xyz",
567            "",
568            "0w",
569            "0month",
570            "-1month",
571            "4294967296month",
572        ] {
573            assert!(text.parse::<Timeframe>().is_err(), "{text:?}");
574        }
575        for text in [r#"{"Weeks":0}"#, r#"{"Months":0}"#] {
576            assert!(serde_json::from_str::<Timeframe>(text).is_err());
577        }
578    }
579
580    #[test]
581    fn deserialize_rejects_zero_length_custom_timeframe() {
582        assert!(serde_json::from_str::<Timeframe>(r#"{"Custom":0}"#).is_err());
583        let tf: Timeframe = serde_json::from_str(r#"{"Custom":45}"#).unwrap();
584        assert_eq!(tf.duration_ms(), 45_000);
585        // Index-based formats must keep reading intervals saved before calendar counts existed.
586        for (index, expected) in [
587            (17_u32, custom(45)),
588            (18, Timeframe::Ticks(NonZeroU64::new(45).unwrap())),
589            (19, Timeframe::Volume(NonZeroU64::new(45).unwrap())),
590            (20, Timeframe::Range(NonZeroU64::new(45).unwrap())),
591            (21, Timeframe::Delta(NonZeroU64::new(45).unwrap())),
592        ] {
593            let map = serde::de::value::MapDeserializer::<_, serde::de::value::Error>::new(
594                [(index, 45_u64)].into_iter(),
595            );
596            assert_eq!(
597                Timeframe::deserialize(serde::de::value::MapAccessDeserializer::new(map)).unwrap(),
598                expected,
599            );
600        }
601    }
602}