openpine_vm/time/
timeframe.rs

1use std::{fmt, num::NonZeroU32, str::FromStr};
2
3use serde::{Deserialize, Deserializer, Serialize};
4use time::{Date, Duration, PrimitiveDateTime, Time, Weekday};
5
6/// Unit component of a [`TimeFrame`].
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub enum TimeUnit {
9    /// Second-based timeframe.
10    Second = 0,
11    /// Minute-based timeframe.
12    Minute = 1,
13    /// Day-based timeframe.
14    Day = 2,
15    /// Week-based timeframe.
16    Week = 3,
17    /// Month-based timeframe.
18    Month = 4,
19    /// Tick-based timeframe.
20    Tick = 5,
21}
22
23/// Represents a timeframe with a quantity and a unit.
24///
25/// Timeframes specify the bar duration for chart data (e.g. 1 day, 5 minutes).
26/// They can be created with convenience constructors, the general
27/// [`new()`](Self::new) method, or by parsing a string.
28///
29/// # Examples
30///
31/// ```
32/// use openpine_vm::{TimeFrame, TimeUnit};
33///
34/// // Convenience constructors
35/// let daily = TimeFrame::days(1);
36/// let five_min = TimeFrame::minutes(5);
37/// let weekly = TimeFrame::weeks(1);
38///
39/// // General constructor
40/// let monthly = TimeFrame::new(3, TimeUnit::Month);
41///
42/// // Parse from TradingView-style strings
43/// let tf: TimeFrame = "D".parse().unwrap(); // 1 day
44/// assert_eq!(tf, TimeFrame::days(1));
45///
46/// let tf: TimeFrame = "60".parse().unwrap(); // 60 minutes
47/// assert_eq!(tf, TimeFrame::minutes(60));
48///
49/// let tf: TimeFrame = "4W".parse().unwrap(); // 4 weeks
50/// assert_eq!(tf, TimeFrame::weeks(4));
51///
52/// // Display back to string
53/// assert_eq!(TimeFrame::days(1).to_string(), "D");
54/// assert_eq!(TimeFrame::minutes(5).to_string(), "5");
55/// assert_eq!(TimeFrame::months(3).to_string(), "3M");
56/// ```
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
58pub struct TimeFrame {
59    /// Number of units in the timeframe.
60    pub quantity: NonZeroU32,
61    /// Base unit for the timeframe.
62    pub unit: TimeUnit,
63}
64
65impl TimeFrame {
66    /// Creates a new `TimeFrame` with the given quantity and unit.
67    #[inline]
68    pub fn new(quantity: u32, unit: TimeUnit) -> Self {
69        TimeFrame {
70            quantity: NonZeroU32::new(quantity).expect("quantity must be non-zero"),
71            unit,
72        }
73    }
74
75    /// Creates a `TimeFrame` representing the given number of seconds.
76    #[inline]
77    pub fn seconds(quantity: u32) -> Self {
78        TimeFrame::new(quantity, TimeUnit::Second)
79    }
80
81    /// Creates a `TimeFrame` representing the given number of minutes.
82    #[inline]
83    pub fn minutes(quantity: u32) -> Self {
84        TimeFrame::new(quantity, TimeUnit::Minute)
85    }
86
87    /// Creates a `TimeFrame` representing the given number of days.
88    #[inline]
89    pub fn days(quantity: u32) -> Self {
90        TimeFrame::new(quantity, TimeUnit::Day)
91    }
92
93    /// Creates a `TimeFrame` representing the given number of weeks.
94    #[inline]
95    pub fn weeks(quantity: u32) -> Self {
96        TimeFrame::new(quantity, TimeUnit::Week)
97    }
98
99    /// Creates a `TimeFrame` representing the given number of months.
100    #[inline]
101    pub fn months(quantity: u32) -> Self {
102        TimeFrame::new(quantity, TimeUnit::Month)
103    }
104
105    /// Creates a `TimeFrame` representing the given number of ticks.
106    #[inline]
107    pub fn ticks(quantity: u32) -> Self {
108        TimeFrame::new(quantity, TimeUnit::Tick)
109    }
110
111    /// Returns the multiplier (quantity) of the timeframe.
112    ///
113    /// For example, '60' - 60, 'D' - 1, '5W' - 5.
114    #[inline]
115    pub fn multiplier(&self) -> u32 {
116        self.quantity.get()
117    }
118
119    /// Returns the timeframe duration in seconds, if representable.
120    pub fn in_seconds(&self) -> Option<u64> {
121        Some(match self.unit {
122            TimeUnit::Second => self.quantity.get() as u64,
123            TimeUnit::Minute => self.quantity.get() as u64 * 60,
124            TimeUnit::Day => self.quantity.get() as u64 * 60 * 60 * 24,
125            TimeUnit::Week => self.quantity.get() as u64 * 60 * 60 * 24 * 7,
126            TimeUnit::Month => self.quantity.get() as u64 * 2628003, // Average month in seconds
127            TimeUnit::Tick => return None,
128        })
129    }
130
131    /// Chooses a coarse [`TimeFrame`] that best fits `seconds`.
132    pub fn from_seconds(seconds: i64) -> Self {
133        const DAYS_IN_SECONDS: i64 = 86400;
134
135        if seconds <= 1 {
136            TimeFrame::new(1, TimeUnit::Second)
137        } else if seconds <= 5 {
138            TimeFrame::new(5, TimeUnit::Second)
139        } else if seconds <= 10 {
140            TimeFrame::new(10, TimeUnit::Second)
141        } else if seconds <= 15 {
142            TimeFrame::new(15, TimeUnit::Second)
143        } else if seconds <= 30 {
144            TimeFrame::new(30, TimeUnit::Second)
145        } else if seconds <= 45 {
146            TimeFrame::new(45, TimeUnit::Second)
147        } else if seconds <= DAYS_IN_SECONDS {
148            TimeFrame::new(((seconds + 59) / 60) as u32, TimeUnit::Minute)
149        } else if seconds <= 366 * DAYS_IN_SECONDS {
150            if seconds % (7 * DAYS_IN_SECONDS) == 0 {
151                let weeks = seconds / (7 * DAYS_IN_SECONDS);
152                TimeFrame::new(weeks as u32, TimeUnit::Week)
153            } else {
154                let days = (seconds + DAYS_IN_SECONDS - 1) / DAYS_IN_SECONDS;
155                TimeFrame::new(days as u32, TimeUnit::Day)
156            }
157        } else {
158            TimeFrame::new(12, TimeUnit::Month)
159        }
160    }
161
162    pub(crate) fn round(
163        &self,
164        session_start: &Time,
165        time: &PrimitiveDateTime,
166    ) -> Option<PrimitiveDateTime> {
167        match self.unit {
168            TimeUnit::Second => {
169                let seconds = self.quantity.get() as i64;
170                let start_seconds = session_start.hour() as i64 * 3600
171                    + session_start.minute() as i64 * 60
172                    + session_start.second() as i64;
173                let current_seconds =
174                    time.hour() as i64 * 3600 + time.minute() as i64 * 60 + time.second() as i64;
175                let offset = ((current_seconds - start_seconds) / seconds) * seconds;
176                Some(time.replace_time(*session_start + Duration::seconds(offset)))
177            }
178            TimeUnit::Minute => {
179                let minutes = self.quantity.get() as i64;
180                let start_minutes =
181                    session_start.hour() as i64 * 60 + session_start.minute() as i64;
182                let current_minutes = time.hour() as i64 * 60 + time.minute() as i64;
183                let offset = ((current_minutes - start_minutes) / minutes) * minutes;
184                Some(time.replace_time(*session_start + Duration::minutes(offset)))
185            }
186            TimeUnit::Day => Some(time.replace_time(Time::MIDNIGHT)),
187            TimeUnit::Week => {
188                Date::from_iso_week_date(time.year(), time.iso_week(), Weekday::Monday)
189                    .map(|date| date.with_time(Time::MIDNIGHT))
190                    .ok()
191            }
192            TimeUnit::Month => Some(PrimitiveDateTime::new(
193                Date::from_calendar_date(time.year(), time.month(), 1).ok()?,
194                Time::MIDNIGHT,
195            )),
196            TimeUnit::Tick => Some(*time),
197        }
198    }
199
200    pub(crate) fn close(
201        &self,
202        session_start: &Time,
203        time: &PrimitiveDateTime,
204    ) -> Option<PrimitiveDateTime> {
205        let time = self.round(session_start, time)?;
206        match self.unit {
207            TimeUnit::Second => Some(time + Duration::seconds(self.quantity.get() as i64)),
208            TimeUnit::Minute => Some(time + Duration::minutes(self.quantity.get() as i64)),
209            TimeUnit::Day => Some(time + Duration::days(self.quantity.get() as i64)),
210            TimeUnit::Week => Some(time + Duration::weeks(self.quantity.get() as i64)),
211            TimeUnit::Month => {
212                let mut month = time.month() as u32 + self.quantity.get();
213                let mut year = time.year();
214                while month > 12 {
215                    month -= 12;
216                    year += 1;
217                }
218                Some(PrimitiveDateTime::new(
219                    Date::from_calendar_date(year, (month as u8).try_into().unwrap(), 1).ok()?,
220                    Time::MIDNIGHT,
221                ))
222            }
223            TimeUnit::Tick => None,
224        }
225    }
226}
227
228/// Error returned when parsing a [`TimeFrame`] fails.
229#[derive(Debug, thiserror::Error)]
230#[error("invalid time frame")]
231#[non_exhaustive]
232pub struct ParseTimeFrameError;
233
234impl FromStr for TimeFrame {
235    type Err = ParseTimeFrameError;
236
237    fn from_str(s: &str) -> Result<Self, Self::Err> {
238        let s = s.trim();
239        let unit = match s.chars().next_back() {
240            Some('S') => TimeUnit::Second,
241            Some('D') => TimeUnit::Day,
242            Some('W') => TimeUnit::Week,
243            Some('M') => TimeUnit::Month,
244            Some('T') => TimeUnit::Tick,
245            _ => {
246                return Ok(TimeFrame {
247                    quantity: s.parse().map_err(|_| ParseTimeFrameError)?,
248                    unit: TimeUnit::Minute,
249                });
250            }
251        };
252        let n_str = &s[..s.len() - 1];
253
254        if n_str.is_empty() {
255            return Ok(TimeFrame {
256                quantity: NonZeroU32::new(1).unwrap(),
257                unit,
258            });
259        }
260
261        n_str
262            .parse()
263            .map_err(|_| ParseTimeFrameError)
264            .map(|quantity| TimeFrame { quantity, unit })
265    }
266}
267
268impl fmt::Display for TimeFrame {
269    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
270        fn write_with_suffix(
271            f: &mut fmt::Formatter<'_>,
272            quantity: NonZeroU32,
273            suffix: &str,
274        ) -> fmt::Result {
275            if quantity.get() > 1 {
276                write!(f, "{}{}", quantity, suffix)
277            } else {
278                f.write_str(suffix)
279            }
280        }
281
282        match self.unit {
283            TimeUnit::Second => write_with_suffix(f, self.quantity, "S"),
284            TimeUnit::Minute => write!(f, "{}", self.quantity),
285            TimeUnit::Day => write_with_suffix(f, self.quantity, "D"),
286            TimeUnit::Week => write_with_suffix(f, self.quantity, "W"),
287            TimeUnit::Month => write_with_suffix(f, self.quantity, "M"),
288            TimeUnit::Tick => write_with_suffix(f, self.quantity, "T"),
289        }
290    }
291}
292
293impl Serialize for TimeFrame {
294    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
295    where
296        S: serde::Serializer,
297    {
298        serializer.collect_str(self)
299    }
300}
301
302impl<'de> Deserialize<'de> for TimeFrame {
303    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
304    where
305        D: Deserializer<'de>,
306    {
307        String::deserialize(deserializer)?
308            .parse()
309            .map_err(serde::de::Error::custom)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn parse_time_frame() {
319        let tf: TimeFrame = "5".parse().unwrap();
320        assert_eq!(tf.quantity.get(), 5);
321        assert_eq!(tf.unit, TimeUnit::Minute);
322
323        let tf: TimeFrame = "D".parse().unwrap();
324        assert_eq!(tf.quantity.get(), 1);
325        assert_eq!(tf.unit, TimeUnit::Day);
326
327        let tf: TimeFrame = "10W".parse().unwrap();
328        assert_eq!(tf.quantity.get(), 10);
329        assert_eq!(tf.unit, TimeUnit::Week);
330
331        let tf: TimeFrame = "M".parse().unwrap();
332        assert_eq!(tf.quantity.get(), 1);
333        assert_eq!(tf.unit, TimeUnit::Month);
334
335        let tf: TimeFrame = "30S".parse().unwrap();
336        assert_eq!(tf.quantity.get(), 30);
337        assert_eq!(tf.unit, TimeUnit::Second);
338
339        let tf: TimeFrame = "T".parse().unwrap();
340        assert_eq!(tf.quantity.get(), 1);
341        assert_eq!(tf.unit, TimeUnit::Tick);
342    }
343}