1mod 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
27pub trait SessionProvider: Send + Sync {
33 fn session_break_between(
36 &self,
37 from: &DateTime<Utc>,
38 to: &DateTime<Utc>,
39 ) -> Option<SessionBreak>;
40
41 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(¤t, &next) {
57 breaks.push(session_break);
58 }
59 current = next;
60 }
61
62 breaks
63 }
64
65 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 fn name(&self) -> &str;
80}
81
82#[derive(Debug, Clone)]
88pub struct SessionBreak {
89 pub ts: DateTime<Utc>,
91 pub break_type: SessionBreakType,
93 pub label: Option<String>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SessionBreakType {
100 Daily,
102 Weekly,
104 Monthly,
106 Custom,
108 Yearly,
110}
111
112#[derive(Debug, Clone)]
118pub struct DailySessionProvider {
119 pub break_hour: u32,
121 pub break_minute: u32,
123 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 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 #[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 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#[derive(Debug, Clone)]
198pub struct WeeklySessionProvider {
199 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 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#[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
270pub struct CompositeSessionProvider {
276 providers: Vec<Box<dyn SessionProvider>>,
277}
278
279impl CompositeSessionProvider {
280 pub fn new() -> Self {
282 Self {
283 providers: Vec::new(),
284 }
285 }
286
287 pub fn add_provider(&mut self, provider: Box<dyn SessionProvider>) {
289 self.providers.push(provider);
290 }
291
292 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 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
328pub 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); assert_eq!(breaks[0].0, 2); 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}