kestrel_chartkit/
session.rs1use crate::model::Bar;
2use std::fmt;
3
4#[cfg(feature = "serde")]
5use serde::{Deserialize, Serialize};
6
7const DAY_SECONDS: i64 = 86_400;
8
9#[derive(Debug, Clone, PartialEq)]
11#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12pub struct SessionConfig {
13 pub start_hour: u8,
14 pub start_minute: u8,
15 pub end_hour: u8,
16 pub end_minute: u8,
17 pub orb_duration_mins: u32,
18 pub utc_offset_seconds: i32,
20}
21
22impl Default for SessionConfig {
23 fn default() -> Self {
24 Self {
25 start_hour: 0,
26 start_minute: 0,
27 end_hour: 0,
28 end_minute: 0,
29 orb_duration_mins: 30,
30 utc_offset_seconds: 0,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SessionConfigError {
37 InvalidStart,
38 InvalidEnd,
39 InvalidUtcOffset,
40}
41
42impl fmt::Display for SessionConfigError {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 f.write_str(match self {
45 Self::InvalidStart => "session start must be a valid hour and minute",
46 Self::InvalidEnd => "session end must be a valid hour and minute",
47 Self::InvalidUtcOffset => "UTC offset must be less than 24 hours",
48 })
49 }
50}
51
52impl std::error::Error for SessionConfigError {}
53
54impl SessionConfig {
55 pub fn validate(&self) -> Result<(), SessionConfigError> {
56 if self.start_hour > 23 || self.start_minute > 59 {
57 return Err(SessionConfigError::InvalidStart);
58 }
59 if self.end_hour > 23 || self.end_minute > 59 {
60 return Err(SessionConfigError::InvalidEnd);
61 }
62 if self.utc_offset_seconds.unsigned_abs() >= DAY_SECONDS as u32 {
63 return Err(SessionConfigError::InvalidUtcOffset);
64 }
65 Ok(())
66 }
67
68 fn start_seconds(&self) -> i64 {
69 i64::from(self.start_hour) * 3_600 + i64::from(self.start_minute) * 60
70 }
71
72 fn end_seconds(&self) -> i64 {
73 i64::from(self.end_hour) * 3_600 + i64::from(self.end_minute) * 60
74 }
75}
76
77#[derive(Debug, Clone)]
79pub struct SessionTracker {
80 config: SessionConfig,
81 orb_high: Option<f64>,
82 orb_low: Option<f64>,
83 session_open_ts: Option<i64>,
84 in_session: bool,
85 in_orb_window: bool,
86 is_new_session_bar: bool,
87}
88
89impl SessionTracker {
90 pub fn new(config: SessionConfig) -> Result<Self, SessionConfigError> {
91 config.validate()?;
92 Ok(Self {
93 config,
94 orb_high: None,
95 orb_low: None,
96 session_open_ts: None,
97 in_session: false,
98 in_orb_window: false,
99 is_new_session_bar: false,
100 })
101 }
102
103 pub fn reset(&mut self) {
104 self.orb_high = None;
105 self.orb_low = None;
106 self.session_open_ts = None;
107 self.in_session = false;
108 self.in_orb_window = false;
109 self.is_new_session_bar = false;
110 }
111
112 pub fn on_bar(&mut self, bar: &Bar) {
114 let local_timestamp = bar.timestamp + i64::from(self.config.utc_offset_seconds);
115 let day = local_timestamp.div_euclid(DAY_SECONDS);
116 let second_of_day = local_timestamp.rem_euclid(DAY_SECONDS);
117 let start = self.config.start_seconds();
118 let end = self.config.end_seconds();
119
120 let session_start_day = if start == end {
121 Some(day)
122 } else if start < end {
123 (second_of_day >= start && second_of_day < end).then_some(day)
124 } else if second_of_day >= start {
125 Some(day)
126 } else if second_of_day < end {
127 Some(day - 1)
128 } else {
129 None
130 };
131
132 let Some(session_start_day) = session_start_day else {
133 self.in_session = false;
134 self.in_orb_window = false;
135 self.is_new_session_bar = false;
136 return;
137 };
138
139 let session_open_local = session_start_day * DAY_SECONDS + start;
140 let session_open_utc = session_open_local - i64::from(self.config.utc_offset_seconds);
141 self.in_session = true;
142 self.is_new_session_bar = self.session_open_ts != Some(session_open_utc);
143
144 if self.is_new_session_bar {
145 self.session_open_ts = Some(session_open_utc);
146 self.orb_high = Some(bar.high);
147 self.orb_low = Some(bar.low);
148 }
149
150 let elapsed = bar.timestamp - session_open_utc;
151 self.in_orb_window =
152 elapsed >= 0 && elapsed < i64::from(self.config.orb_duration_mins).saturating_mul(60);
153 if self.in_orb_window && !self.is_new_session_bar {
154 self.orb_high = Some(self.orb_high.unwrap_or(bar.high).max(bar.high));
155 self.orb_low = Some(self.orb_low.unwrap_or(bar.low).min(bar.low));
156 }
157 }
158
159 pub fn is_new_session(&self) -> bool {
160 self.is_new_session_bar
161 }
162
163 pub fn in_session(&self) -> bool {
164 self.in_session
165 }
166
167 pub fn session_open_timestamp(&self) -> Option<i64> {
168 self.session_open_ts
169 }
170
171 pub fn orb_high(&self) -> Option<f64> {
172 self.orb_high
173 }
174
175 pub fn orb_low(&self) -> Option<f64> {
176 self.orb_low
177 }
178
179 pub fn in_orb_window(&self) -> bool {
180 self.in_orb_window
181 }
182
183 pub fn orb_range(&self) -> Option<f64> {
185 match (self.orb_high, self.orb_low) {
186 (Some(h), Some(l)) if h >= l => Some(h - l),
187 _ => None,
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 fn bar(timestamp: i64, high: f64, low: f64) -> Bar {
197 Bar::new(timestamp, 100.0, high, low, 100.0, 1_000.0)
198 }
199
200 #[test]
201 fn observes_end_and_orb_boundaries() {
202 let mut tracker = SessionTracker::new(SessionConfig {
203 start_hour: 14,
204 start_minute: 30,
205 end_hour: 21,
206 end_minute: 0,
207 orb_duration_mins: 30,
208 utc_offset_seconds: 0,
209 })
210 .unwrap();
211
212 tracker.on_bar(&bar(52_200, 105.0, 99.0));
213 assert!(tracker.is_new_session());
214 tracker.on_bar(&bar(53_100, 108.0, 98.0));
215 assert_eq!(tracker.orb_high(), Some(108.0));
216 tracker.on_bar(&bar(75_600, 110.0, 90.0));
217 assert!(!tracker.in_session());
218 assert!(!tracker.in_orb_window());
219 }
220
221 #[test]
222 fn supports_overnight_sessions_and_fixed_offsets() {
223 let mut tracker = SessionTracker::new(SessionConfig {
224 start_hour: 22,
225 start_minute: 0,
226 end_hour: 2,
227 end_minute: 0,
228 orb_duration_mins: 60,
229 utc_offset_seconds: 3_600,
230 })
231 .unwrap();
232
233 tracker.on_bar(&bar(21 * 3_600, 105.0, 99.0)); assert!(tracker.is_new_session());
235 tracker.on_bar(&bar(24 * 3_600, 106.0, 98.0)); assert!(tracker.in_session());
237 assert!(!tracker.is_new_session());
238 tracker.on_bar(&bar(25 * 3_600, 106.0, 98.0)); assert!(!tracker.in_session());
240 }
241}