Skip to main content

kestrel_chartkit/
session.rs

1use crate::model::Bar;
2use std::fmt;
3
4#[cfg(feature = "serde")]
5use serde::{Deserialize, Serialize};
6
7const DAY_SECONDS: i64 = 86_400;
8
9/// Provider-neutral recurring trading session configuration.
10#[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    /// Fixed local-time offset from UTC. Named timezones and DST belong in a calendar adapter.
19    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/// Session membership and opening-range state for a stream of bars.
78#[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    /// Processes a bar using its opening timestamp.
113    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    /// Opening range width (`orb_high - orb_low`). Returns `None` if ORB has not formed.
184    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)); // 22:00 local
234        assert!(tracker.is_new_session());
235        tracker.on_bar(&bar(24 * 3_600, 106.0, 98.0)); // 01:00 local next day
236        assert!(tracker.in_session());
237        assert!(!tracker.is_new_session());
238        tracker.on_bar(&bar(25 * 3_600, 106.0, 98.0)); // 02:00 local, end-exclusive
239        assert!(!tracker.in_session());
240    }
241}