Skip to main content

egui_charts/model/
timeframe.rs

1//! Timeframe (bar aggregation interval) for financial charts.
2//!
3//! A [`Timeframe`] specifies how long each bar/candle represents.  The library
4//! ships with standard presets from 100 ms up to 1 month, 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::str::FromStr;
26
27/// Timeframe for bar aggregation.
28///
29/// Each variant represents a fixed time interval over which OHLCV data is aggregated
30/// into a single [`Bar`](super::Bar). The default timeframe is [`Min1`](Timeframe::Min1).
31///
32/// Timeframes are divided into tiers:
33///
34/// | Tier         | Variants                                               |
35/// |-------------|-------------------------------------------------------|
36/// | Millisecond | `Ms100`, `Ms250`, `Ms500`                              |
37/// | Second      | `Sec1` .. `Sec30`                                      |
38/// | Minute      | `Min1`, `Min5`, `Min15`, `Min30`                       |
39/// | Hourly      | `Hour1`, `Hour4`                                       |
40/// | Daily+      | `Day1`, `Week1`, `Month1`                              |
41/// | Custom      | `Custom(seconds)` -- any user-defined interval          |
42///
43/// # Example
44///
45/// ```
46/// use egui_charts::model::Timeframe;
47///
48/// let tf = Timeframe::Hour4;
49/// assert_eq!(tf.duration_ms(), 14_400_000);
50/// assert_eq!(tf.to_string(), "4h");
51/// ```
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53pub enum Timeframe {
54    // Millisecond timeframes (for high-frequency trading)
55    /// 100-millisecond bars (HFT).
56    Ms100,
57    /// 250-millisecond bars (HFT).
58    Ms250,
59    /// 500-millisecond bars (HFT).
60    Ms500,
61
62    // Second timeframes
63    /// 1-second bars.
64    Sec1,
65    /// 2-second bars.
66    Sec2,
67    /// 5-second bars.
68    Sec5,
69    /// 10-second bars.
70    Sec10,
71    /// 30-second bars.
72    Sec30,
73
74    // Minute timeframes
75    /// 1-minute bars (default).
76    Min1,
77    /// 5-minute bars.
78    Min5,
79    /// 15-minute bars.
80    Min15,
81    /// 30-minute bars.
82    Min30,
83
84    // Hourly timeframes
85    /// 1-hour bars.
86    Hour1,
87    /// 4-hour bars.
88    Hour4,
89
90    // Daily and longer timeframes
91    /// Daily bars.
92    Day1,
93    /// Weekly bars.
94    Week1,
95    /// Monthly bars (approximated as 30 days for duration calculations).
96    Month1,
97
98    /// Custom timeframe defined in seconds.
99    /// Allows user-defined intervals (e.g., 45s, 3min, 2h).
100    Custom(u64),
101}
102
103impl Timeframe {
104    /// Returns the canonical string label for this timeframe (e.g. `"1min"`, `"4h"`, `"1D"`).
105    pub fn as_str(&self) -> Cow<'static, str> {
106        match self {
107            Timeframe::Ms100 => Cow::Borrowed("100ms"),
108            Timeframe::Ms250 => Cow::Borrowed("250ms"),
109            Timeframe::Ms500 => Cow::Borrowed("500ms"),
110            Timeframe::Sec1 => Cow::Borrowed("1s"),
111            Timeframe::Sec2 => Cow::Borrowed("2s"),
112            Timeframe::Sec5 => Cow::Borrowed("5s"),
113            Timeframe::Sec10 => Cow::Borrowed("10s"),
114            Timeframe::Sec30 => Cow::Borrowed("30s"),
115            Timeframe::Min1 => Cow::Borrowed("1min"),
116            Timeframe::Min5 => Cow::Borrowed("5min"),
117            Timeframe::Min15 => Cow::Borrowed("15min"),
118            Timeframe::Min30 => Cow::Borrowed("30min"),
119            Timeframe::Hour1 => Cow::Borrowed("1h"),
120            Timeframe::Hour4 => Cow::Borrowed("4h"),
121            Timeframe::Day1 => Cow::Borrowed("1D"),
122            Timeframe::Week1 => Cow::Borrowed("1W"),
123            Timeframe::Month1 => Cow::Borrowed("1M"),
124            Timeframe::Custom(seconds) => Cow::Owned(format_custom_seconds(*seconds)),
125        }
126    }
127
128    /// Returns all preset (non-custom) timeframes in ascending order.
129    pub fn all() -> Vec<Timeframe> {
130        vec![
131            Timeframe::Ms100,
132            Timeframe::Ms250,
133            Timeframe::Ms500,
134            Timeframe::Sec1,
135            Timeframe::Sec2,
136            Timeframe::Sec5,
137            Timeframe::Sec10,
138            Timeframe::Sec30,
139            Timeframe::Min1,
140            Timeframe::Min5,
141            Timeframe::Min15,
142            Timeframe::Min30,
143            Timeframe::Hour1,
144            Timeframe::Hour4,
145            Timeframe::Day1,
146            Timeframe::Week1,
147            Timeframe::Month1,
148        ]
149    }
150
151    /// Get duration in milliseconds
152    pub fn duration_ms(&self) -> i64 {
153        match self {
154            Timeframe::Ms100 => 100,
155            Timeframe::Ms250 => 250,
156            Timeframe::Ms500 => 500,
157            Timeframe::Sec1 => 1000,
158            Timeframe::Sec2 => 2000,
159            Timeframe::Sec5 => 5000,
160            Timeframe::Sec10 => 10000,
161            Timeframe::Sec30 => 30000,
162            Timeframe::Min1 => 60_000,
163            Timeframe::Min5 => 300_000,
164            Timeframe::Min15 => 900_000,
165            Timeframe::Min30 => 1_800_000,
166            Timeframe::Hour1 => 3_600_000,
167            Timeframe::Hour4 => 14_400_000,
168            Timeframe::Day1 => 86_400_000,
169            Timeframe::Week1 => 604_800_000,
170            Timeframe::Month1 => 2_592_000_000, // Approximation: 30 days
171            Timeframe::Custom(seconds) => (*seconds as i64) * 1000,
172        }
173    }
174
175    /// Get chrono Duration
176    pub fn duration(&self) -> Duration {
177        Duration::milliseconds(self.duration_ms())
178    }
179
180    /// Returns true if this is a user-defined custom timeframe
181    pub fn is_custom(&self) -> bool {
182        matches!(self, Timeframe::Custom(_))
183    }
184
185    /// Get the number of seconds for this timeframe
186    pub fn total_seconds(&self) -> u64 {
187        (self.duration_ms() / 1000).max(0) as u64
188    }
189
190    /// Convert timeframe to seconds (signed, for range calculations)
191    pub fn as_seconds(self) -> i64 {
192        self.total_seconds() as i64
193    }
194
195    /// Alias for `as_seconds` for backward compatibility
196    pub fn to_seconds(self) -> i64 {
197        self.as_seconds()
198    }
199
200    /// Convert a resolution string to Timeframe
201    pub fn from_resolution(resolution: &str) -> Option<Self> {
202        match resolution {
203            "1" => Some(Timeframe::Min1),
204            "5" => Some(Timeframe::Min5),
205            "15" => Some(Timeframe::Min15),
206            "30" => Some(Timeframe::Min30),
207            "60" | "1H" => Some(Timeframe::Hour1),
208            "240" | "4H" => Some(Timeframe::Hour4),
209            "1D" | "D" => Some(Timeframe::Day1),
210            "1W" | "W" => Some(Timeframe::Week1),
211            "1M" | "M" => Some(Timeframe::Month1),
212            _ => None,
213        }
214    }
215}
216
217/// Format a custom seconds value into a human-readable label.
218/// Chooses the largest clean unit: days > hours > minutes > seconds.
219fn format_custom_seconds(seconds: u64) -> String {
220    if seconds == 0 {
221        return "0s".to_string();
222    }
223    if seconds >= 86400 && seconds.is_multiple_of(86400) {
224        format!("{}D", seconds / 86400)
225    } else if seconds >= 3600 && seconds.is_multiple_of(3600) {
226        format!("{}h", seconds / 3600)
227    } else if seconds >= 60 && seconds.is_multiple_of(60) {
228        format!("{}min", seconds / 60)
229    } else {
230        format!("{seconds}s")
231    }
232}
233
234impl Default for Timeframe {
235    /// Default timeframe is 1 minute
236    fn default() -> Self {
237        Timeframe::Min1
238    }
239}
240
241impl fmt::Display for Timeframe {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        write!(f, "{}", self.as_str())
244    }
245}
246
247impl FromStr for Timeframe {
248    type Err = String;
249
250    fn from_str(s: &str) -> Result<Self, Self::Err> {
251        // Try known preset names first
252        match s.to_lowercase().as_str() {
253            "100ms" => return Ok(Timeframe::Ms100),
254            "250ms" => return Ok(Timeframe::Ms250),
255            "500ms" => return Ok(Timeframe::Ms500),
256            "1s" | "1sec" => return Ok(Timeframe::Sec1),
257            "2s" | "2sec" => return Ok(Timeframe::Sec2),
258            "5s" | "5sec" => return Ok(Timeframe::Sec5),
259            "10s" | "10sec" => return Ok(Timeframe::Sec10),
260            "30s" | "30sec" => return Ok(Timeframe::Sec30),
261            "1min" => return Ok(Timeframe::Min1),
262            "5min" => return Ok(Timeframe::Min5),
263            "15min" => return Ok(Timeframe::Min15),
264            "30min" => return Ok(Timeframe::Min30),
265            "1h" | "1hour" => return Ok(Timeframe::Hour1),
266            "4h" | "4hour" => return Ok(Timeframe::Hour4),
267            "1d" | "1day" => return Ok(Timeframe::Day1),
268            "1w" | "1week" => return Ok(Timeframe::Week1),
269            "1m" | "1month" => return Ok(Timeframe::Month1),
270            _ => {}
271        }
272
273        // Try parsing as custom timeframe: "<number><unit>"
274        let lower = s.to_lowercase();
275        if let Some(seconds) = parse_custom_timeframe(&lower) {
276            if seconds == 0 {
277                return Err("Custom timeframe must be greater than zero".to_string());
278            }
279            return Ok(Timeframe::Custom(seconds));
280        }
281
282        Err(format!(
283            "Invalid timeframe '{s}'. Valid formats: 100ms, 250ms, 500ms, 1s-30s, 1min-30min, 1h, 4h, 1D, 1W, 1M, or custom (e.g. 45s, 3min, 2h)"
284        ))
285    }
286}
287
288/// Parse a custom timeframe string like "45s", "3min", "2h", "2d"
289/// and return the total number of seconds.
290///
291/// Note: "m" suffix is intentionally excluded to avoid ambiguity with months.
292/// Use "min" for minutes (e.g. "3min", "45min").
293fn parse_custom_timeframe(s: &str) -> Option<u64> {
294    let suffixes: &[(&str, u64)] = &[
295        ("month", 2_592_000),
296        ("hour", 3600),
297        ("min", 60),
298        ("sec", 1),
299        ("day", 86400),
300        ("s", 1),
301        ("h", 3600),
302        ("d", 86400),
303        ("w", 604_800),
304    ];
305
306    for &(suffix, multiplier) in suffixes {
307        if let Some(num_str) = s.strip_suffix(suffix) {
308            let num: u64 = num_str.trim().parse().ok()?;
309            return Some(num * multiplier);
310        }
311    }
312
313    // Try bare number as minutes (common convention)
314    let num: u64 = s.trim().parse().ok()?;
315    Some(num * 60)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn test_preset_as_str() {
324        assert_eq!(&*Timeframe::Min1.as_str(), "1min");
325        assert_eq!(&*Timeframe::Hour4.as_str(), "4h");
326        assert_eq!(&*Timeframe::Day1.as_str(), "1D");
327    }
328
329    #[test]
330    fn test_custom_as_str() {
331        assert_eq!(&*Timeframe::Custom(45).as_str(), "45s");
332        assert_eq!(&*Timeframe::Custom(180).as_str(), "3min");
333        assert_eq!(&*Timeframe::Custom(7200).as_str(), "2h");
334        assert_eq!(&*Timeframe::Custom(172800).as_str(), "2D");
335    }
336
337    #[test]
338    fn test_custom_duration_ms() {
339        assert_eq!(Timeframe::Custom(45).duration_ms(), 45_000);
340        assert_eq!(Timeframe::Custom(180).duration_ms(), 180_000);
341        assert_eq!(Timeframe::Custom(7200).duration_ms(), 7_200_000);
342    }
343
344    #[test]
345    fn test_is_custom() {
346        assert!(!Timeframe::Min1.is_custom());
347        assert!(!Timeframe::Day1.is_custom());
348        assert!(Timeframe::Custom(45).is_custom());
349        assert!(Timeframe::Custom(180).is_custom());
350    }
351
352    #[test]
353    fn test_from_str_presets() {
354        assert_eq!("1min".parse::<Timeframe>(), Ok(Timeframe::Min1));
355        assert_eq!("1h".parse::<Timeframe>(), Ok(Timeframe::Hour1));
356        assert_eq!("1d".parse::<Timeframe>(), Ok(Timeframe::Day1));
357        assert_eq!("1w".parse::<Timeframe>(), Ok(Timeframe::Week1));
358        assert_eq!("1m".parse::<Timeframe>(), Ok(Timeframe::Month1));
359    }
360
361    #[test]
362    fn test_from_str_custom() {
363        assert_eq!("45s".parse::<Timeframe>(), Ok(Timeframe::Custom(45)));
364        assert_eq!("45sec".parse::<Timeframe>(), Ok(Timeframe::Custom(45)));
365        assert_eq!("3min".parse::<Timeframe>(), Ok(Timeframe::Custom(180)));
366        assert_eq!("2h".parse::<Timeframe>(), Ok(Timeframe::Custom(7200)));
367        assert_eq!("2hour".parse::<Timeframe>(), Ok(Timeframe::Custom(7200)));
368        assert_eq!("2d".parse::<Timeframe>(), Ok(Timeframe::Custom(172800)));
369        assert_eq!("2day".parse::<Timeframe>(), Ok(Timeframe::Custom(172800)));
370    }
371
372    #[test]
373    fn test_from_str_custom_zero_rejected() {
374        assert!("0s".parse::<Timeframe>().is_err());
375        assert!("0min".parse::<Timeframe>().is_err());
376    }
377
378    #[test]
379    fn test_from_str_invalid() {
380        assert!("xyz".parse::<Timeframe>().is_err());
381        assert!("".parse::<Timeframe>().is_err());
382    }
383
384    #[test]
385    fn test_custom_display() {
386        assert_eq!(Timeframe::Custom(45).to_string(), "45s");
387        assert_eq!(Timeframe::Custom(180).to_string(), "3min");
388        assert_eq!(Timeframe::Custom(7200).to_string(), "2h");
389    }
390
391    #[test]
392    fn test_total_seconds() {
393        assert_eq!(Timeframe::Min1.total_seconds(), 60);
394        assert_eq!(Timeframe::Hour1.total_seconds(), 3600);
395        assert_eq!(Timeframe::Custom(45).total_seconds(), 45);
396    }
397
398    #[test]
399    fn test_custom_equality() {
400        assert_eq!(Timeframe::Custom(60), Timeframe::Custom(60));
401        assert_ne!(Timeframe::Custom(60), Timeframe::Custom(120));
402        // Custom(60) is NOT equal to Min1 - they are different variants
403        assert_ne!(Timeframe::Custom(60), Timeframe::Min1);
404    }
405
406    #[test]
407    fn test_preset_from_str_takes_precedence() {
408        // "1s" should resolve to preset Sec1, not Custom(1)
409        assert_eq!("1s".parse::<Timeframe>(), Ok(Timeframe::Sec1));
410        assert_eq!("5min".parse::<Timeframe>(), Ok(Timeframe::Min5));
411        assert_eq!("1h".parse::<Timeframe>(), Ok(Timeframe::Hour1));
412    }
413}