Skip to main content

egui_charts/model/session/
mod.rs

1//! Trading session management and break detection.
2//!
3//! Financial markets operate in defined sessions (e.g., NYSE 9:30 AM--4:00 PM
4//! ET). This module provides a pluggable [`SessionProvider`] trait for
5//! detecting session boundaries (daily, weekly, monthly, or custom) within
6//! bar data, plus concrete implementations:
7//!
8//! | Provider | Detects |
9//! |----------|---------|
10//! | [`DailySessionProvider`] | Day boundaries (configurable break hour) |
11//! | [`ExchangeSessionProvider`] | Session opens of an instrument's trading hours |
12//! | [`WeeklySessionProvider`] | Week boundaries (configurable break day) |
13//! | [`MonthlySessionProvider`] | Month boundaries |
14//! | [`CompositeSessionProvider`] | Combines multiple providers |
15//!
16//! Use [`find_session_breaks`] to scan a bar slice for all breaks.
17
18mod exchange;
19
20pub use exchange::ExchangeSessionProvider;
21#[cfg(feature = "widget")]
22pub(crate) use exchange::MarketSession;
23
24use crate::model::{Bar, TimezoneId, local_instant, local_time};
25use chrono::{DateTime, Datelike, Duration, NaiveTime, Utc, Weekday};
26
27/// Trait for defining trading session boundaries.
28///
29/// Implement this to create custom session-break logic (e.g., exchange-
30/// specific holidays, pre-market/after-hours splits, or futures roll
31/// dates). All built-in providers implement this trait.
32pub trait SessionProvider: Send + Sync {
33    /// Check if a session break occurs between two ts
34    /// Returns Some(SessionBreak) if there's a break, None otherwise
35    fn session_break_between(
36        &self,
37        from: &DateTime<Utc>,
38        to: &DateTime<Utc>,
39    ) -> Option<SessionBreak>;
40
41    /// Get all session breaks within a time range, checking at most one minute at a time.
42    /// The final check stops at `end`; an empty or reversed range has no breaks.
43    fn session_breaks_in_range(
44        &self,
45        start: &DateTime<Utc>,
46        end: &DateTime<Utc>,
47    ) -> Vec<SessionBreak> {
48        let mut breaks = Vec::new();
49        let mut current = *start;
50
51        while current < *end {
52            let next = current
53                .checked_add_signed(Duration::minutes(1))
54                .unwrap_or(*end)
55                .min(*end);
56            if let Some(session_break) = self.session_break_between(&current, &next) {
57                breaks.push(session_break);
58            }
59            current = next;
60        }
61
62        breaks
63    }
64
65    /// The breaks between consecutive `bars`, as [`find_session_breaks`] returns them. The
66    /// default asks [`session_break_between`](Self::session_break_between) for each pair; a
67    /// provider can find them in one pass instead.
68    fn session_breaks(&self, bars: &[Bar]) -> Vec<(usize, SessionBreak)> {
69        let pairs = bars.windows(2).enumerate();
70        pairs
71            .filter_map(|(i, pair)| {
72                let session_break = self.session_break_between(&pair[0].time, &pair[1].time)?;
73                Some((i + 1, session_break))
74            })
75            .collect()
76    }
77
78    /// Get display name for this session provider
79    fn name(&self) -> &str;
80}
81
82/// A detected session break with metadata.
83///
84/// Produced by [`SessionProvider::session_break_between`] when a session
85/// boundary falls between two timestamps. Contains the break time, type,
86/// and an optional human-readable label for rendering on the time axis.
87#[derive(Debug, Clone)]
88pub struct SessionBreak {
89    /// Ts of the break
90    pub ts: DateTime<Utc>,
91    /// Type of session break
92    pub break_type: SessionBreakType,
93    /// Optional label to display
94    pub label: Option<String>,
95}
96
97/// Classification of a session break.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SessionBreakType {
100    /// Daily session break (end of trading day)
101    Daily,
102    /// Weekly session break (end of trading week)
103    Weekly,
104    /// Monthly session break (end of trading month)
105    Monthly,
106    /// Custom session break
107    Custom,
108    /// Yearly session break (end of calendar year)
109    Yearly,
110}
111
112/// Daily session provider with a configurable break hour.
113///
114/// Detects day boundaries at the break hour and minute, in its zone (UTC by default). Use `nyse` (with `timezones`) for a preset
115/// matching NYSE regular-session close in exchange time, or [`continuous`](Self::continuous)
116/// for 24/7 markets (midnight UTC).
117#[derive(Debug, Clone)]
118pub struct DailySessionProvider {
119    /// Hour of day for session break (0-23)
120    pub break_hour: u32,
121    /// Minute of hour for session break (0-59)
122    pub break_minute: u32,
123    /// Zone in which the daily break is defined.
124    pub time_zone: TimezoneId,
125}
126
127impl Default for DailySessionProvider {
128    fn default() -> Self {
129        Self::new(0, 0)
130    }
131}
132
133impl DailySessionProvider {
134    /// Create a daily provider that breaks at the given UTC hour and minute.
135    pub fn new(hour: u32, minute: u32) -> Self {
136        Self {
137            break_hour: hour.min(23),
138            break_minute: minute.min(59),
139            time_zone: TimezoneId::utc(),
140        }
141    }
142
143    /// Create provider for NYSE regular session (9:30 AM - 4:00 PM ET)
144    #[cfg(feature = "timezones")]
145    pub fn nyse() -> Self {
146        Self {
147            time_zone: TimezoneId::named("America/New_York").unwrap(),
148            ..Self::new(16, 0)
149        }
150    }
151
152    /// Create provider for 24/7 markets (midnight UTC)
153    pub fn continuous() -> Self {
154        Self::default()
155    }
156}
157
158impl SessionProvider for DailySessionProvider {
159    fn session_break_between(
160        &self,
161        from: &DateTime<Utc>,
162        to: &DateTime<Utc>,
163    ) -> Option<SessionBreak> {
164        let day = |time: &DateTime<Utc>| {
165            let local = local_time(self.time_zone.clone(), *time);
166            let clock =
167                NaiveTime::from_hms_opt(self.break_hour.min(23), self.break_minute.min(59), 0)
168                    .unwrap();
169            let boundary = local_instant(self.time_zone.clone(), local.date().and_time(clock));
170            if boundary.is_some_and(|boundary| *time < boundary) {
171                local.date().pred_opt().unwrap_or(local.date())
172            } else {
173                local.date()
174            }
175        };
176
177        if day(from) != day(to) {
178            Some(SessionBreak {
179                ts: *to,
180                break_type: SessionBreakType::Daily,
181                label: Some(day(to).format("%Y-%m-%d").to_string()),
182            })
183        } else {
184            None
185        }
186    }
187
188    fn name(&self) -> &str {
189        "Daily Sessions"
190    }
191}
192
193/// Weekly session provider that detects boundaries after a configured UTC weekday.
194///
195/// Defaults to Sunday, so a week starts on Monday. Configure with
196/// [`new`](Self::new) to use a different day.
197#[derive(Debug, Clone)]
198pub struct WeeklySessionProvider {
199    /// Last weekday of the session week; the break is at the following midnight UTC.
200    pub break_day: Weekday,
201}
202
203impl Default for WeeklySessionProvider {
204    fn default() -> Self {
205        Self {
206            break_day: Weekday::Sun,
207        }
208    }
209}
210
211impl WeeklySessionProvider {
212    /// Create a weekly provider that breaks at midnight UTC after `break_day`.
213    pub fn new(break_day: Weekday) -> Self {
214        Self { break_day }
215    }
216}
217
218impl SessionProvider for WeeklySessionProvider {
219    fn session_break_between(
220        &self,
221        from: &DateTime<Utc>,
222        to: &DateTime<Utc>,
223    ) -> Option<SessionBreak> {
224        let week = |time: &DateTime<Utc>| {
225            (time.num_days_from_ce() - self.break_day.number_from_monday() as i32 - 1).div_euclid(7)
226        };
227
228        if week(from) != week(to) {
229            Some(SessionBreak {
230                ts: *to,
231                break_type: SessionBreakType::Weekly,
232                label: Some(format!("Week {}", to.iso_week().week())),
233            })
234        } else {
235            None
236        }
237    }
238
239    fn name(&self) -> &str {
240        "Weekly Sessions"
241    }
242}
243
244/// Monthly session provider that detects calendar-month boundaries.
245#[derive(Debug, Clone, Default)]
246pub struct MonthlySessionProvider;
247
248impl SessionProvider for MonthlySessionProvider {
249    fn session_break_between(
250        &self,
251        from: &DateTime<Utc>,
252        to: &DateTime<Utc>,
253    ) -> Option<SessionBreak> {
254        if from.month() != to.month() || from.year() != to.year() {
255            Some(SessionBreak {
256                ts: *to,
257                break_type: SessionBreakType::Monthly,
258                label: Some(format!("{}", to.format("%B %Y"))),
259            })
260        } else {
261            None
262        }
263    }
264
265    fn name(&self) -> &str {
266        "Monthly Sessions"
267    }
268}
269
270/// Composite session provider that combines multiple providers.
271///
272/// Providers added earlier take priority: the first provider that reports a break wins.
273/// Use [`standard`](Self::standard) for the common
274/// daily + weekly + monthly combination.
275pub struct CompositeSessionProvider {
276    providers: Vec<Box<dyn SessionProvider>>,
277}
278
279impl CompositeSessionProvider {
280    /// Create an empty composite (no providers yet).
281    pub fn new() -> Self {
282        Self {
283            providers: Vec::new(),
284        }
285    }
286
287    /// Add a session provider to the composite.
288    pub fn add_provider(&mut self, provider: Box<dyn SessionProvider>) {
289        self.providers.push(provider);
290    }
291
292    /// Create standard breaks with monthly priority over weekly, then daily.
293    pub fn standard() -> Self {
294        let mut composite = Self::new();
295        composite.add_provider(Box::new(MonthlySessionProvider));
296        composite.add_provider(Box::new(WeeklySessionProvider::default()));
297        composite.add_provider(Box::new(DailySessionProvider::default()));
298        composite
299    }
300}
301
302impl Default for CompositeSessionProvider {
303    fn default() -> Self {
304        Self::new()
305    }
306}
307
308impl SessionProvider for CompositeSessionProvider {
309    fn session_break_between(
310        &self,
311        from: &DateTime<Utc>,
312        to: &DateTime<Utc>,
313    ) -> Option<SessionBreak> {
314        // Return the first reporting provider, in insertion order.
315        for provider in &self.providers {
316            if let Some(break_info) = provider.session_break_between(from, to) {
317                return Some(break_info);
318            }
319        }
320        None
321    }
322
323    fn name(&self) -> &str {
324        "Composite Sessions"
325    }
326}
327
328/// Scan a bar slice for session breaks.
329///
330/// Returns a `Vec` of `(bar_index, SessionBreak)` pairs indicating
331/// where each break falls. The `bar_index` is the index of the bar
332/// *after* the break (i.e., the first bar of the new session).
333pub fn find_session_breaks(
334    bars: &[Bar],
335    provider: &dyn SessionProvider,
336) -> Vec<(usize, SessionBreak)> {
337    provider.session_breaks(bars)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use chrono::TimeZone;
344
345    fn create_test_bars() -> Vec<Bar> {
346        vec![
347            Bar {
348                time: Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap(),
349                open: 100.0,
350                high: 105.0,
351                low: 99.0,
352                close: 102.0,
353                volume: 1000.0,
354                colors: None,
355                payload: None,
356            },
357            Bar {
358                time: Utc.with_ymd_and_hms(2024, 1, 2, 12, 0, 0).unwrap(),
359                open: 102.0,
360                high: 107.0,
361                low: 101.0,
362                close: 105.0,
363                volume: 1200.0,
364                colors: None,
365                payload: None,
366            },
367            Bar {
368                time: Utc.with_ymd_and_hms(2024, 2, 1, 12, 0, 0).unwrap(),
369                open: 105.0,
370                high: 110.0,
371                low: 104.0,
372                close: 108.0,
373                volume: 1500.0,
374                colors: None,
375                payload: None,
376            },
377        ]
378    }
379
380    #[test]
381    fn test_daily_session_break() {
382        for (provider, before, at) in [
383            (
384                DailySessionProvider::new(21, 0),
385                "2024-03-14T20:59:59Z",
386                "2024-03-14T21:00:00Z",
387            ),
388            #[cfg(feature = "timezones")]
389            (
390                DailySessionProvider::nyse(),
391                "2024-03-11T19:59:59Z",
392                "2024-03-11T20:00:00Z",
393            ),
394            #[cfg(feature = "timezones")]
395            (
396                DailySessionProvider::nyse(),
397                "2024-11-04T20:59:59Z",
398                "2024-11-04T21:00:00Z",
399            ),
400            (
401                DailySessionProvider {
402                    time_zone: TimezoneId::fixed_offset(5 * 3600 + 45 * 60).unwrap(),
403                    ..DailySessionProvider::new(16, 0)
404                },
405                "2024-03-14T10:14:59Z",
406                "2024-03-14T10:15:00Z",
407            ),
408            (
409                DailySessionProvider::continuous(),
410                "2024-01-01T12:00:00Z",
411                "2025-01-01T12:00:00Z",
412            ),
413        ] {
414            let before = before.parse().unwrap();
415            let at = at.parse().unwrap();
416            assert!(provider.session_break_between(&before, &at).is_some());
417            assert!(
418                provider
419                    .session_break_between(&at, &(at + Duration::seconds(1)))
420                    .is_none()
421            );
422        }
423        assert_eq!(
424            find_session_breaks(&create_test_bars(), &DailySessionProvider::default()).len(),
425            2
426        );
427    }
428
429    #[test]
430    fn session_range_stays_within_its_end_at_timestamp_limits() {
431        let midnight = Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap();
432        let max = DateTime::<Utc>::MAX_UTC;
433        let min = DateTime::<Utc>::MIN_UTC;
434        for (start, end, expected) in [
435            (max - Duration::seconds(1), max, None),
436            (min, min + Duration::seconds(1), None),
437            (max, max, None),
438            (max, min, None),
439            (
440                midnight - Duration::seconds(30),
441                midnight - Duration::seconds(1),
442                None,
443            ),
444            (midnight - Duration::seconds(30), midnight, Some(midnight)),
445            (
446                midnight - Duration::seconds(30),
447                midnight + Duration::seconds(10),
448                Some(midnight + Duration::seconds(10)),
449            ),
450        ] {
451            let breaks = DailySessionProvider::continuous().session_breaks_in_range(&start, &end);
452            assert_eq!(
453                breaks.iter().map(|item| item.ts).collect::<Vec<_>>(),
454                expected.into_iter().collect::<Vec<_>>()
455            );
456        }
457    }
458
459    #[test]
460    fn test_monthly_session_break() {
461        let provider = MonthlySessionProvider;
462        let bars = create_test_bars();
463
464        let breaks = find_session_breaks(&bars, &provider);
465        assert_eq!(breaks.len(), 1); // 1 month change (Jan -> Feb)
466
467        assert_eq!(breaks[0].0, 2); // Break at index 2
468        assert_eq!(breaks[0].1.break_type, SessionBreakType::Monthly);
469    }
470
471    #[test]
472    fn test_composite_provider() {
473        let composite = CompositeSessionProvider::standard();
474        let mut bars = create_test_bars();
475        bars.push(Bar::new(
476            Utc.with_ymd_and_hms(2024, 2, 5, 12, 0, 0).unwrap(),
477            1.0,
478            1.0,
479            1.0,
480            1.0,
481            1.0,
482        ));
483        let breaks = find_session_breaks(&bars, &composite);
484        assert_eq!(
485            breaks
486                .iter()
487                .map(|(index, item)| (*index, item.break_type))
488                .collect::<Vec<_>>(),
489            [
490                (1, SessionBreakType::Daily),
491                (2, SessionBreakType::Monthly),
492                (3, SessionBreakType::Weekly)
493            ]
494        );
495        let mut custom = CompositeSessionProvider::new();
496        custom.add_provider(Box::new(DailySessionProvider::default()));
497        custom.add_provider(Box::new(MonthlySessionProvider));
498        assert_eq!(
499            find_session_breaks(&bars, &custom)[1].1.break_type,
500            SessionBreakType::Daily
501        );
502    }
503
504    #[test]
505    fn weekly_breaks_follow_the_configured_day_across_years() {
506        for (provider, from, to, expected) in [
507            (
508                WeeklySessionProvider::new(Weekday::Wed),
509                "2024-03-12T12:00:00Z",
510                "2024-03-14T12:00:00Z",
511                true,
512            ),
513            (
514                WeeklySessionProvider::new(Weekday::Wed),
515                "2024-03-17T12:00:00Z",
516                "2024-03-18T12:00:00Z",
517                false,
518            ),
519            (
520                WeeklySessionProvider::new(Weekday::Wed),
521                "2024-03-13T23:59:59Z",
522                "2024-03-14T00:00:00Z",
523                true,
524            ),
525            (
526                WeeklySessionProvider::default(),
527                "2024-03-17T23:59:59Z",
528                "2024-03-18T00:00:00Z",
529                true,
530            ),
531            (
532                WeeklySessionProvider::default(),
533                "2024-03-15T23:59:59Z",
534                "2024-03-16T00:00:00Z",
535                false,
536            ),
537            (
538                WeeklySessionProvider::default(),
539                "2024-01-03T12:00:00Z",
540                "2025-01-01T12:00:00Z",
541                true,
542            ),
543        ] {
544            assert_eq!(
545                provider
546                    .session_break_between(&from.parse().unwrap(), &to.parse().unwrap())
547                    .is_some(),
548                expected,
549                "{from}..{to}"
550            );
551        }
552    }
553}