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
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn bar(timestamp: i64, high: f64, low: f64) -> Bar {
189        Bar::new(timestamp, 100.0, high, low, 100.0, 1_000.0)
190    }
191
192    #[test]
193    fn observes_end_and_orb_boundaries() {
194        let mut tracker = SessionTracker::new(SessionConfig {
195            start_hour: 14,
196            start_minute: 30,
197            end_hour: 21,
198            end_minute: 0,
199            orb_duration_mins: 30,
200            utc_offset_seconds: 0,
201        })
202        .unwrap();
203
204        tracker.on_bar(&bar(52_200, 105.0, 99.0));
205        assert!(tracker.is_new_session());
206        tracker.on_bar(&bar(53_100, 108.0, 98.0));
207        assert_eq!(tracker.orb_high(), Some(108.0));
208        tracker.on_bar(&bar(75_600, 110.0, 90.0));
209        assert!(!tracker.in_session());
210        assert!(!tracker.in_orb_window());
211    }
212
213    #[test]
214    fn supports_overnight_sessions_and_fixed_offsets() {
215        let mut tracker = SessionTracker::new(SessionConfig {
216            start_hour: 22,
217            start_minute: 0,
218            end_hour: 2,
219            end_minute: 0,
220            orb_duration_mins: 60,
221            utc_offset_seconds: 3_600,
222        })
223        .unwrap();
224
225        tracker.on_bar(&bar(21 * 3_600, 105.0, 99.0)); // 22:00 local
226        assert!(tracker.is_new_session());
227        tracker.on_bar(&bar(24 * 3_600, 106.0, 98.0)); // 01:00 local next day
228        assert!(tracker.in_session());
229        assert!(!tracker.is_new_session());
230        tracker.on_bar(&bar(25 * 3_600, 106.0, 98.0)); // 02:00 local, end-exclusive
231        assert!(!tracker.in_session());
232    }
233}