Skip to main content

qs_backtest/strategy/
session.rs

1//! Neutral fixed-UTC session membership as a configured named input.
2
3use chrono::{NaiveTime, Timelike};
4use qs_strategy::{ScalarType, Value, ValueType};
5
6use super::configured::{
7    HistoricalNamedInputProjector, NamedInputProjectionContext, NamedInputProjectionError,
8    ProjectedNamedInput,
9};
10
11const SECONDS_PER_DAY: u32 = 86_400;
12
13/// Maximum number of windows one session projector accepts.
14pub const MAX_SESSION_WINDOWS: usize = 16;
15
16/// Invalid fixed-UTC session configuration.
17#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
18pub enum FixedUtcSessionError {
19    #[error("a session needs at least one window")]
20    NoWindows,
21    #[error("a session accepts at most {maximum} windows, got {actual}")]
22    TooManyWindows { actual: usize, maximum: usize },
23    #[error("session window {index} starts and ends at the same time")]
24    EmptyWindow { index: usize },
25}
26
27/// Reports whether a boundary falls inside any fixed UTC window, as a required boolean named input.
28///
29/// Each window is half-open, from its start up to but excluding its end, measured in seconds since UTC midnight; a window whose end is earlier than its start wraps past midnight. Sub-second parts of the boundary time are ignored. Sessions that follow a venue calendar or daylight-saving rule remain caller-owned projectors.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct FixedUtcSessionProjector {
32    windows: Vec<(u32, u32)>,
33}
34
35impl FixedUtcSessionProjector {
36    pub fn new(
37        windows: impl IntoIterator<Item = (NaiveTime, NaiveTime)>,
38    ) -> Result<Self, FixedUtcSessionError> {
39        let windows = windows
40            .into_iter()
41            .map(|(start, end)| {
42                (
43                    start.num_seconds_from_midnight(),
44                    end.num_seconds_from_midnight(),
45                )
46            })
47            .collect::<Vec<_>>();
48        if windows.is_empty() {
49            return Err(FixedUtcSessionError::NoWindows);
50        }
51        if windows.len() > MAX_SESSION_WINDOWS {
52            return Err(FixedUtcSessionError::TooManyWindows {
53                actual: windows.len(),
54                maximum: MAX_SESSION_WINDOWS,
55            });
56        }
57        if let Some(index) = windows.iter().position(|(start, end)| start == end) {
58            return Err(FixedUtcSessionError::EmptyWindow { index });
59        }
60        Ok(Self { windows })
61    }
62
63    /// Whether the given seconds since UTC midnight fall inside any window.
64    pub fn contains(&self, seconds_of_day: u32) -> bool {
65        let second = seconds_of_day % SECONDS_PER_DAY;
66        self.windows.iter().any(|&(start, end)| {
67            if start < end {
68                (start..end).contains(&second)
69            } else {
70                second >= start || second < end
71            }
72        })
73    }
74}
75
76impl HistoricalNamedInputProjector for FixedUtcSessionProjector {
77    fn output_type(&self) -> ValueType {
78        ValueType::required(ScalarType::Bool)
79    }
80
81    fn project(
82        &self,
83        context: NamedInputProjectionContext<'_>,
84    ) -> Result<ProjectedNamedInput, NamedInputProjectionError> {
85        Ok(ProjectedNamedInput {
86            value: Value::Bool(self.contains(context.observed_through.num_seconds_from_midnight())),
87            updated: true,
88        })
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    fn at(hour: u32, minute: u32) -> NaiveTime {
97        NaiveTime::from_hms_opt(hour, minute, 0).unwrap()
98    }
99
100    #[test]
101    fn windows_are_half_open_and_may_wrap_past_midnight() {
102        let london = FixedUtcSessionProjector::new([(at(7, 0), at(16, 0))]).unwrap();
103        assert!(!london.contains(at(6, 59).num_seconds_from_midnight() + 59));
104        assert!(london.contains(at(7, 0).num_seconds_from_midnight()));
105        assert!(london.contains(at(15, 59).num_seconds_from_midnight() + 59));
106        assert!(!london.contains(at(16, 0).num_seconds_from_midnight()));
107
108        let overnight = FixedUtcSessionProjector::new([(at(22, 0), at(2, 0))]).unwrap();
109        assert!(overnight.contains(at(23, 30).num_seconds_from_midnight()));
110        assert!(overnight.contains(at(0, 0).num_seconds_from_midnight()));
111        assert!(overnight.contains(at(1, 59).num_seconds_from_midnight()));
112        assert!(!overnight.contains(at(2, 0).num_seconds_from_midnight()));
113        assert!(!overnight.contains(at(12, 0).num_seconds_from_midnight()));
114    }
115
116    #[test]
117    fn empty_missing_and_excess_windows_are_rejected() {
118        assert_eq!(
119            FixedUtcSessionProjector::new([]).unwrap_err(),
120            FixedUtcSessionError::NoWindows
121        );
122        assert_eq!(
123            FixedUtcSessionProjector::new([(at(1, 0), at(2, 0)), (at(3, 0), at(3, 0))])
124                .unwrap_err(),
125            FixedUtcSessionError::EmptyWindow { index: 1 }
126        );
127        let many = (0..=MAX_SESSION_WINDOWS as u32).map(|hour| (at(hour, 0), at(hour, 30)));
128        assert!(matches!(
129            FixedUtcSessionProjector::new(many).unwrap_err(),
130            FixedUtcSessionError::TooManyWindows { .. }
131        ));
132    }
133}