Skip to main content

quickfix_tokio/
schedule.rs

1//! Session schedules: when a session is active and when it resets, ported
2//! from quickfix C++'s `TimeRange`.
3//!
4//! A [`Schedule`] is a daily window (`StartTime`/`EndTime`) or a weekly one
5//! (adding `StartDay`/`EndDay`). [`Schedule::is_in_range`] answers "is now
6//! inside the window"; [`Schedule::is_in_same_range`] answers "do these two
7//! instants fall in the *same occurrence* of the window" — the test that
8//! drives the daily/weekly sequence-number reset.
9
10use chrono::{DateTime, Datelike, Local, Timelike, Utc};
11
12use crate::error::{Error, Result};
13
14const SECONDS_PER_DAY: i64 = 86_400;
15
16/// A time of day as seconds since midnight (`0..86400`).
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18struct Tod(i64);
19
20impl Tod {
21    fn parse(s: &str) -> Result<Self> {
22        let bad = || Error::Config(format!("invalid time {s:?}, expected HH:MM:SS"));
23        let mut parts = s.split(':');
24        let h: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
25        let m: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
26        let sec: i64 = parts.next().ok_or_else(bad)?.parse().map_err(|_| bad())?;
27        if parts.next().is_some() || h > 23 || m > 59 || sec > 59 {
28            return Err(bad());
29        }
30        Ok(Tod(h * 3600 + m * 60 + sec))
31    }
32}
33
34/// Parse a day abbreviation (first two letters, case-insensitive) to the
35/// C++ weekday numbering: Sunday=1 .. Saturday=7.
36fn parse_day(s: &str) -> Result<i32> {
37    let abbr: String = s.chars().take(2).flat_map(|c| c.to_lowercase()).collect();
38    Ok(match abbr.as_str() {
39        "su" => 1,
40        "mo" => 2,
41        "tu" => 3,
42        "we" => 4,
43        "th" => 5,
44        "fr" => 6,
45        "sa" => 7,
46        _ => return Err(Error::Config(format!("invalid day {s:?}"))),
47    })
48}
49
50/// Weekday of a date in C++ numbering (Sunday=1 .. Saturday=7).
51fn weekday(date: chrono::NaiveDate) -> i32 {
52    date.weekday().num_days_from_sunday() as i32 + 1
53}
54
55/// A monotonic day count, consistent with [`weekday`] (both advance by one
56/// per calendar day), used as the C++ "Julian date" for weekly anchoring.
57fn day_number(date: chrono::NaiveDate) -> i32 {
58    date.num_days_from_ce()
59}
60
61#[derive(Debug, Clone)]
62pub struct Schedule {
63    start: Tod,
64    end: Tod,
65    /// -1 when this is a daily (non-weekly) window.
66    start_day: i32,
67    end_day: i32,
68    use_local: bool,
69    /// True when the session is 24/7 (either `NonStopSession=Y` or no times
70    /// configured, our friendlier default).
71    non_stop: bool,
72}
73
74impl Default for Schedule {
75    /// No schedule configured: always active, never resets.
76    fn default() -> Self {
77        Self {
78            start: Tod(0),
79            end: Tod(0),
80            start_day: -1,
81            end_day: -1,
82            use_local: false,
83            non_stop: true,
84        }
85    }
86}
87
88impl Schedule {
89    pub fn non_stop() -> Self {
90        Self::default()
91    }
92
93    /// Build from raw config strings. Any of `start`/`end` unset yields a
94    /// non-stop schedule (unless days are given). Returns a config error on
95    /// malformed times/days or a StartDay-without-EndDay mismatch.
96    pub fn parse(
97        start: Option<&str>,
98        end: Option<&str>,
99        start_day: Option<&str>,
100        end_day: Option<&str>,
101        use_local: bool,
102        non_stop_setting: bool,
103    ) -> Result<Self> {
104        let start_day = start_day.map(parse_day).transpose()?.unwrap_or(-1);
105        let end_day = end_day.map(parse_day).transpose()?.unwrap_or(-1);
106        if (start_day >= 0) != (end_day >= 0) {
107            return Err(Error::Config("StartDay and EndDay must be set together".into()));
108        }
109        match (start, end) {
110            (Some(s), Some(e)) => Ok(Self {
111                start: Tod::parse(s)?,
112                end: Tod::parse(e)?,
113                start_day,
114                end_day,
115                use_local,
116                non_stop: false,
117            }),
118            _ if non_stop_setting || start_day < 0 => Ok(Self::non_stop()),
119            // Days set but no times: default to midnight-to-midnight (a full
120            // weekly window).
121            _ => Ok(Self {
122                start: Tod(0),
123                end: Tod(0),
124                start_day,
125                end_day,
126                use_local,
127                non_stop: false,
128            }),
129        }
130    }
131
132    pub fn is_non_stop(&self) -> bool {
133        self.non_stop
134    }
135
136    /// (time-of-day seconds, weekday 1..7, day number) in the schedule's zone.
137    fn parts(&self, now: DateTime<Utc>) -> (i64, i32, i32) {
138        if self.use_local {
139            let l = now.with_timezone(&Local);
140            (
141                l.time().num_seconds_from_midnight() as i64,
142                weekday(l.date_naive()),
143                day_number(l.date_naive()),
144            )
145        } else {
146            (
147                now.time().num_seconds_from_midnight() as i64,
148                weekday(now.date_naive()),
149                day_number(now.date_naive()),
150            )
151        }
152    }
153
154    /// Is `now` within the active window?
155    pub fn is_in_range(&self, now: DateTime<Utc>) -> bool {
156        if self.non_stop {
157            return true;
158        }
159        let (tod, wd, _) = self.parts(now);
160        if self.start_day < 0 {
161            in_daily(self.start, self.end, tod)
162        } else {
163            self.in_weekly(tod, wd)
164        }
165    }
166
167    fn in_weekly(&self, tod: i64, day: i32) -> bool {
168        let (sd, ed) = (self.start_day, self.end_day);
169        if sd == ed {
170            if day != sd {
171                return true;
172            }
173            return in_daily(self.start, self.end, tod);
174        } else if sd < ed {
175            if day < sd || day > ed {
176                return false;
177            }
178        } else {
179            // sd > ed: window wraps across the week boundary.
180            if day < sd && day > ed {
181                return false;
182            }
183        }
184        if day == sd && tod < self.start.0 {
185            return false;
186        }
187        if day == ed && tod > self.end.0 {
188            return false;
189        }
190        true
191    }
192
193    /// Do `now` and `other` fall in the same occurrence of the window? When
194    /// this is false, the session has crossed into a new instance and its
195    /// sequence numbers should reset.
196    pub fn is_in_same_range(&self, now: DateTime<Utc>, other: DateTime<Utc>) -> bool {
197        if self.non_stop {
198            return true;
199        }
200        if !self.is_in_range(now) || !self.is_in_range(other) {
201            return false;
202        }
203        if now == other {
204            return true;
205        }
206        if self.start_day < 0 {
207            self.same_daily(now, other)
208        } else if self.start_day != self.end_day {
209            self.range_start_date(now) == self.range_start_date(other)
210        } else {
211            self.same_weekly_sameday(now, other)
212        }
213    }
214
215    fn same_daily(&self, t1: DateTime<Utc>, t2: DateTime<Utc>) -> bool {
216        if self.start.0 <= self.end.0 {
217            // Non-overnight (incl. start == end = 24h): same calendar date.
218            self.parts(t1).2 == self.parts(t2).2
219        } else {
220            // Overnight wrap: within one contiguous session span.
221            let session_length = SECONDS_PER_DAY - (self.start.0 - self.end.0);
222            let diff = (t1 - t2).num_seconds();
223            if diff > 0 {
224                let t2_tod = self.parts(t2).0;
225                let mut delta = t2_tod - self.start.0;
226                if delta < 0 {
227                    delta = SECONDS_PER_DAY - delta.abs();
228                }
229                diff < (session_length - delta)
230            } else {
231                -diff < session_length
232            }
233        }
234    }
235
236    /// The day-number of the current weekly window's start (C++
237    /// `getRangeStartDate`): the most recent `start_day` at `start`.
238    fn range_start_date(&self, now: DateTime<Utc>) -> i32 {
239        let (tod, wd, jul) = self.parts(now);
240        let sd = self.start_day;
241        if wd > sd {
242            jul - wd + sd
243        } else if wd < sd {
244            jul - wd + sd - 7
245        } else if tod >= self.start.0 {
246            jul
247        } else {
248            jul - 7
249        }
250    }
251
252    fn same_weekly_sameday(&self, t1: DateTime<Utc>, t2: DateTime<Utc>) -> bool {
253        let (tod1, wd1, day1) = self.parts(t1);
254        let (tod2, _wd2, day2) = self.parts(t2);
255        let sd = self.start_day;
256        if day1 == day2 && sd == wd1 {
257            let both_before_end = tod1 <= self.end.0 && tod2 <= self.end.0;
258            let both_after_start = tod1 >= self.start.0 && tod2 >= self.start.0;
259            both_before_end || both_after_start
260        } else if day1 == day2 && sd != wd1 {
261            true
262        } else if (day1 - day2).abs() > 7 {
263            false
264        } else if (day1 - day2).abs() == 7 {
265            if wd1 != sd {
266                return false;
267            }
268            let (earlier_tod, later_tod) =
269                if day2 > day1 { (tod1, tod2) } else { (tod2, tod1) };
270            earlier_tod >= self.start.0 && later_tod <= self.end.0
271        } else {
272            self.range_start_date(t1) == self.range_start_date(t2)
273        }
274    }
275}
276
277fn in_daily(start: Tod, end: Tod, tod: i64) -> bool {
278    if start.0 < end.0 {
279        tod >= start.0 && tod <= end.0
280    } else {
281        // start > end (overnight) or start == end (always active).
282        tod >= start.0 || tod <= end.0
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use chrono::TimeZone;
290
291    fn utc(y: i32, mo: u32, d: u32, h: u32, mi: u32, s: u32) -> DateTime<Utc> {
292        Utc.with_ymd_and_hms(y, mo, d, h, mi, s).unwrap()
293    }
294
295    fn daily(start: &str, end: &str) -> Schedule {
296        Schedule::parse(Some(start), Some(end), None, None, false, false).unwrap()
297    }
298
299    #[test]
300    fn parse_days_and_times() {
301        assert!(Tod::parse("00:00:00").is_ok());
302        assert!(Tod::parse("23:59:59").is_ok());
303        assert!(Tod::parse("24:00:00").is_err());
304        assert!(Tod::parse("12:60:00").is_err());
305        assert!(Tod::parse("12:00").is_err());
306        assert_eq!(parse_day("Sunday").unwrap(), 1);
307        assert_eq!(parse_day("SA").unwrap(), 7);
308        assert_eq!(parse_day("we").unwrap(), 4);
309        assert!(parse_day("xy").is_err());
310    }
311
312    #[test]
313    fn weekday_numbering_matches_cpp() {
314        // 2024-05-19 is a Sunday.
315        assert_eq!(weekday(chrono::NaiveDate::from_ymd_opt(2024, 5, 19).unwrap()), 1);
316        assert_eq!(weekday(chrono::NaiveDate::from_ymd_opt(2024, 5, 25).unwrap()), 7); // Saturday
317    }
318
319    #[test]
320    fn daytime_window() {
321        let s = daily("08:00:00", "17:00:00");
322        assert!(!s.is_in_range(utc(2024, 5, 20, 7, 59, 59)));
323        assert!(s.is_in_range(utc(2024, 5, 20, 8, 0, 0)));
324        assert!(s.is_in_range(utc(2024, 5, 20, 12, 0, 0)));
325        assert!(s.is_in_range(utc(2024, 5, 20, 17, 0, 0)));
326        assert!(!s.is_in_range(utc(2024, 5, 20, 17, 0, 1)));
327    }
328
329    #[test]
330    fn daily_reset_same_and_different_day() {
331        let s = daily("08:00:00", "17:00:00");
332        // Same day, both in window -> same range.
333        assert!(s.is_in_same_range(utc(2024, 5, 20, 9, 0, 0), utc(2024, 5, 20, 16, 0, 0)));
334        // Next day -> different range (a reset boundary was crossed).
335        assert!(!s.is_in_same_range(utc(2024, 5, 21, 9, 0, 0), utc(2024, 5, 20, 16, 0, 0)));
336    }
337
338    #[test]
339    fn always_on_window_resets_at_midnight() {
340        // StartTime == EndTime: 24h window that resets once per calendar day.
341        let s = daily("00:00:00", "00:00:00");
342        assert!(s.is_in_range(utc(2024, 5, 20, 3, 0, 0)));
343        assert!(s.is_in_same_range(utc(2024, 5, 20, 3, 0, 0), utc(2024, 5, 20, 22, 0, 0)));
344        assert!(!s.is_in_same_range(utc(2024, 5, 21, 1, 0, 0), utc(2024, 5, 20, 22, 0, 0)));
345    }
346
347    #[test]
348    fn overnight_window() {
349        // 17:00 -> 08:00 next day.
350        let s = daily("17:00:00", "08:00:00");
351        assert!(s.is_in_range(utc(2024, 5, 20, 23, 0, 0)));
352        assert!(s.is_in_range(utc(2024, 5, 21, 3, 0, 0)));
353        assert!(!s.is_in_range(utc(2024, 5, 20, 12, 0, 0)));
354        // Evening and the following early morning are the SAME session.
355        assert!(s.is_in_same_range(utc(2024, 5, 20, 23, 0, 0), utc(2024, 5, 21, 3, 0, 0)));
356        // Two nights apart -> different sessions.
357        assert!(!s.is_in_same_range(utc(2024, 5, 20, 23, 0, 0), utc(2024, 5, 21, 23, 30, 0)));
358    }
359
360    #[test]
361    fn weekly_window() {
362        // Sunday 00:00 -> Friday 17:00 (a classic FX week).
363        let s = Schedule::parse(
364            Some("00:00:00"),
365            Some("17:00:00"),
366            Some("Sunday"),
367            Some("Friday"),
368            false,
369            false,
370        )
371        .unwrap();
372        // 2024-05-19 Sun .. 2024-05-24 Fri.
373        assert!(s.is_in_range(utc(2024, 5, 19, 1, 0, 0))); // Sunday
374        assert!(s.is_in_range(utc(2024, 5, 22, 12, 0, 0))); // Wednesday
375        assert!(s.is_in_range(utc(2024, 5, 24, 17, 0, 0))); // Friday 17:00
376        assert!(!s.is_in_range(utc(2024, 5, 24, 17, 0, 1))); // just after close
377        assert!(!s.is_in_range(utc(2024, 5, 25, 12, 0, 0))); // Saturday
378        // Wed and Fri of the same week -> same session.
379        assert!(s.is_in_same_range(utc(2024, 5, 22, 12, 0, 0), utc(2024, 5, 24, 12, 0, 0)));
380        // Across the weekend into the next week -> different session.
381        assert!(!s.is_in_same_range(utc(2024, 5, 24, 12, 0, 0), utc(2024, 5, 27, 12, 0, 0)));
382    }
383
384    #[test]
385    fn non_stop_never_resets() {
386        let s = Schedule::non_stop();
387        assert!(s.is_in_range(utc(2024, 5, 20, 3, 0, 0)));
388        assert!(s.is_in_same_range(utc(2024, 1, 1, 0, 0, 0), utc(2024, 12, 31, 23, 59, 59)));
389    }
390}