qs_backtest/strategy/
session.rs1use 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
13pub const MAX_SESSION_WINDOWS: usize = 16;
15
16#[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#[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 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}