Skip to main content

pine_core/
timeframe.rs

1/// The unit of a [`Timeframe`]. Pine writes each as a suffix on the multiplier
2/// (`"3D"`, `"5S"`, `"1W"`); minutes have no suffix (`"60"`).
3#[derive(Clone, Copy, Debug, PartialEq, Eq)]
4pub enum TimeframeUnit {
5    Ticks,
6    Seconds,
7    Minutes,
8    Daily,
9    Weekly,
10    Monthly,
11}
12
13impl TimeframeUnit {
14    /// The suffix Pine writes for this unit (minutes have none).
15    pub fn suffix(self) -> &'static str {
16        match self {
17            TimeframeUnit::Ticks => "T",
18            TimeframeUnit::Seconds => "S",
19            TimeframeUnit::Minutes => "",
20            TimeframeUnit::Daily => "D",
21            TimeframeUnit::Weekly => "W",
22            TimeframeUnit::Monthly => "M",
23        }
24    }
25
26    /// The unit a Pine suffix names
27    pub fn from_suffix(suffix: &str) -> Option<Self> {
28        Some(match suffix {
29            "T" => TimeframeUnit::Ticks,
30            "S" => TimeframeUnit::Seconds,
31            "" => TimeframeUnit::Minutes,
32            "D" => TimeframeUnit::Daily,
33            "W" => TimeframeUnit::Weekly,
34            "M" => TimeframeUnit::Monthly,
35            _ => return None,
36        })
37    }
38
39    /// The length of one of this unit in milliseconds
40    pub fn millis(self) -> Option<i64> {
41        Some(match self {
42            TimeframeUnit::Seconds => 1_000,
43            TimeframeUnit::Minutes => 60_000,
44            TimeframeUnit::Daily => 86_400_000,
45            TimeframeUnit::Weekly => 604_800_000,
46            TimeframeUnit::Ticks | TimeframeUnit::Monthly => return None,
47        })
48    }
49}
50
51/// The chart timeframe a script runs on, exposed as `timeframe.*`.
52///
53/// A multiplier plus a unit, e.g. `{ 3, Daily }` → `timeframe.period == "3D"`.
54#[derive(Clone, Debug)]
55pub struct Timeframe {
56    pub multiplier: u32,
57    pub unit: TimeframeUnit,
58}
59
60impl Default for Timeframe {
61    fn default() -> Self {
62        Self {
63            multiplier: 1,
64            unit: TimeframeUnit::Daily,
65        }
66    }
67}
68
69/// Why an interval string could not be read as a timeframe.
70#[derive(Debug, thiserror::Error, PartialEq, Eq)]
71#[error("unrecognised timeframe {0:?}")]
72pub struct TimeframeError(pub String);
73
74impl std::str::FromStr for Timeframe {
75    type Err = TimeframeError;
76
77    /// Parse a Pine period string (`"5"`, `"60"`, `"1D"`, `"30S"`, `"1W"`).
78    fn from_str(period: &str) -> Result<Self, Self::Err> {
79        let bad = || TimeframeError(period.to_string());
80        let split = period
81            .find(|c: char| !c.is_ascii_digit())
82            .unwrap_or(period.len());
83        let (count, suffix) = period.split_at(split);
84        Ok(Self {
85            multiplier: count.parse().ok().filter(|&m| m > 0).ok_or_else(bad)?,
86            unit: TimeframeUnit::from_suffix(suffix).ok_or_else(bad)?,
87        })
88    }
89}
90
91/// The fixed-length units, coarsest first: a weekly gap is also a whole number
92/// of days and of minutes, and the coarsest unit that divides it is the one Pine
93/// would name.
94const REGULAR_UNITS: [TimeframeUnit; 4] = [
95    TimeframeUnit::Weekly,
96    TimeframeUnit::Daily,
97    TimeframeUnit::Minutes,
98    TimeframeUnit::Seconds,
99];
100
101impl Timeframe {
102    /// The whole timeframe expressed in minutes. `None` for sub-minute and month
103    /// periods, which have no whole-minute length.
104    pub fn as_minutes(&self) -> Option<u32> {
105        let per_unit = match self.unit {
106            TimeframeUnit::Minutes => 1,
107            TimeframeUnit::Daily => 60 * 24,
108            TimeframeUnit::Weekly => 60 * 24 * 7,
109            TimeframeUnit::Ticks | TimeframeUnit::Seconds | TimeframeUnit::Monthly => return None,
110        };
111        self.multiplier.checked_mul(per_unit)
112    }
113
114    /// The period covered by a gap of `millis` between two bars, or `None` if
115    /// no whole unit divides it.
116    ///
117    /// Months are never inferred: their length varies, so a monthly series has
118    /// no single gap to recognise.
119    pub fn from_millis(millis: i64) -> Option<Self> {
120        if millis <= 0 {
121            return None;
122        }
123
124        REGULAR_UNITS
125            .into_iter()
126            .filter_map(|unit| Some((unit, unit.millis()?)))
127            .find(|(_, size)| millis % size == 0)
128            .map(|(unit, size)| Self {
129                multiplier: (millis / size) as u32,
130                unit,
131            })
132    }
133
134    /// The Pine period string, e.g. `"3D"`, `"60"`, `"5S"`.
135    pub fn period(&self) -> String {
136        format!("{}{}", self.multiplier, self.unit.suffix())
137    }
138
139    pub fn to_millis(&self) -> Option<i64> {
140        Some(self.unit.millis()? * i64::from(self.multiplier))
141    }
142
143    pub fn is_seconds(&self) -> bool {
144        self.unit == TimeframeUnit::Seconds
145    }
146
147    pub fn is_minutes(&self) -> bool {
148        self.unit == TimeframeUnit::Minutes
149    }
150
151    pub fn is_daily(&self) -> bool {
152        self.unit == TimeframeUnit::Daily
153    }
154
155    pub fn is_weekly(&self) -> bool {
156        self.unit == TimeframeUnit::Weekly
157    }
158
159    pub fn is_monthly(&self) -> bool {
160        self.unit == TimeframeUnit::Monthly
161    }
162
163    pub fn is_ticks(&self) -> bool {
164        self.unit == TimeframeUnit::Ticks
165    }
166
167    /// Intraday timeframes are seconds or minutes.
168    pub fn is_intraday(&self) -> bool {
169        self.is_seconds() || self.is_minutes()
170    }
171
172    /// Day/week/month timeframes.
173    pub fn is_dwm(&self) -> bool {
174        self.is_daily() || self.is_weekly() || self.is_monthly()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::Timeframe;
181    use std::str::FromStr;
182
183    #[test]
184    fn parses_pine_period_notation() {
185        // Round-trips through `period`: a Pine string parses to the same string.
186        for period in ["30S", "5", "60", "240", "1D", "1W", "1M"] {
187            assert_eq!(Timeframe::from_str(period).unwrap().period(), period);
188        }
189        // Minutes have no suffix, so a bare number is minutes.
190        assert!(Timeframe::from_str("5").unwrap().is_minutes());
191    }
192
193    #[test]
194    fn parses_millisecond_lengths() {
195        assert_eq!(
196            Timeframe::from_str("30S").unwrap().to_millis(),
197            Some(30_000)
198        );
199        assert_eq!(Timeframe::from_str("5").unwrap().to_millis(), Some(300_000));
200        assert_eq!(
201            Timeframe::from_str("1D").unwrap().to_millis(),
202            Some(86_400_000)
203        );
204        // Months and ticks have no fixed length.
205        assert_eq!(Timeframe::from_str("1M").unwrap().to_millis(), None);
206    }
207
208    #[test]
209    fn an_unreadable_interval_is_an_error() {
210        assert!(Timeframe::from_str("").is_err());
211        assert!(Timeframe::from_str("hourly").is_err());
212        assert!(Timeframe::from_str("1y").is_err()); // provider "1y" is not Pine
213        assert!(Timeframe::from_str("5m").is_err()); // provider spelling, not Pine
214        assert!(Timeframe::from_str("d1").is_err());
215    }
216}