brokapi_bcs/map/
schedule.rs1use std::time::Instant;
13
14use brokapi_bcs_protocol::http::models::{DailyScheduleResponse, ScheduleEntry};
15use brokapi_core::{Schedule, SessionInterval, SessionKind, UnixNanos};
16use chrono::{NaiveDate, NaiveTime};
17
18use super::timestamps;
19
20const MSK_OFFSET_SECS: i64 = 3 * 3600;
22
23pub fn map_daily_schedule(
30 exchange: &str,
31 day: NaiveDate,
32 resp: &DailyScheduleResponse,
33 now: Instant,
34) -> Schedule {
35 let sessions = if resp.is_work_day {
37 resp.daily_schedule
38 .iter()
39 .filter(|e| e.trading_session_status.eq_ignore_ascii_case("OPEN"))
40 .filter_map(|e| map_session(e, day))
41 .collect()
42 } else {
43 Vec::new()
44 };
45 Schedule {
46 exchange: exchange.to_string(),
47 sessions,
48 ts: timestamps(now, None),
49 }
50}
51
52fn map_session(entry: &ScheduleEntry, day: NaiveDate) -> Option<SessionInterval> {
53 let start = parse_schedule_ts(&entry.start_date, day)?;
54 let end = parse_schedule_ts(&entry.end_date, day)?;
55 Some(SessionInterval {
56 start,
57 end,
58 kind: map_session_kind(&entry.trading_session_type),
59 })
60}
61
62fn map_session_kind(raw: &str) -> SessionKind {
63 let lower = raw.to_lowercase();
64 if lower.contains("аукцион") || lower.contains("auction") {
65 SessionKind::Auction
66 } else if lower.contains("вечерн") || lower.contains("evening") {
67 SessionKind::PostMarket
68 } else if lower.contains("торговый период") || lower.contains("main") || lower.contains("open")
69 {
70 SessionKind::Main
71 } else {
72 SessionKind::Other
73 }
74}
75
76fn parse_schedule_ts(raw: &str, day: NaiveDate) -> Option<UnixNanos> {
77 if let Ok(t) = NaiveTime::parse_from_str(raw, "%H:%M:%S") {
78 let local = day.and_time(t).and_utc().timestamp() - MSK_OFFSET_SECS;
79 return Some(UnixNanos::from_nanos(local * 1_000_000_000));
80 }
81 chrono::DateTime::parse_from_rfc3339(raw)
82 .ok()
83 .map(|dt| super::unix_nanos(dt.with_timezone(&chrono::Utc)))
84 .or_else(|| {
85 chrono::NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S%.3fZ")
86 .ok()
87 .map(|ndt| super::unix_nanos(ndt.and_utc()))
88 })
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 fn resp(json: &str) -> DailyScheduleResponse {
96 serde_json::from_str(json).unwrap()
97 }
98
99 #[test]
100 fn times_of_day_are_put_on_the_requested_day_in_moscow_time() {
101 let r = resp(
102 r#"{"isWorkDay":true,"dailySchedule":[
103 {"startDate":"09:50:00","endDate":"10:00:00","tradingSessionType":"Аукцион открытия","tradingSessionStatus":"OPEN"},
104 {"startDate":"10:00:00","endDate":"18:50:00","tradingSessionType":"Торговый период","tradingSessionStatus":"OPEN"},
105 {"startDate":"18:50:00","endDate":"19:00:00","tradingSessionType":"Технический перерыв QUIK","tradingSessionStatus":"CLOSE"},
106 {"startDate":"19:05:00","endDate":"23:50:00","tradingSessionType":"Вечерняя торговая сессия","tradingSessionStatus":"OPEN"}]}"#,
107 );
108 let day = NaiveDate::from_ymd_opt(2026, 10, 19).unwrap();
109 let s = map_daily_schedule("SBER@TQBR", day, &r, Instant::now());
110 assert_eq!(
111 s.sessions.len(),
112 3,
113 "the CLOSE interval is a break, not a session"
114 );
115 let kinds: Vec<_> = s.sessions.iter().map(|x| x.kind).collect();
116 assert_eq!(
117 kinds,
118 vec![
119 SessionKind::Auction,
120 SessionKind::Main,
121 SessionKind::PostMarket
122 ]
123 );
124 let main = &s.sessions[1];
126 let utc7 = day.and_hms_opt(7, 0, 0).unwrap().and_utc().timestamp() * 1_000_000_000;
127 assert_eq!(main.start.as_nanos(), utc7);
128 assert!(main.end.as_nanos() > main.start.as_nanos());
129 }
130
131 #[test]
132 fn a_day_off_has_no_sessions() {
133 let r = resp(
134 r#"{"isWorkDay":false,"dailySchedule":[{"startDate":"10:00:00","endDate":"18:50:00","tradingSessionType":"Торговый период","tradingSessionStatus":"OPEN"}]}"#,
135 );
136 let s = map_daily_schedule(
137 "SBER@TQBR",
138 NaiveDate::from_ymd_opt(2026, 10, 18).unwrap(),
139 &r,
140 Instant::now(),
141 );
142 assert!(s.sessions.is_empty());
143 }
144
145 #[test]
146 fn full_datetimes_are_still_read() {
147 let r = resp(
148 r#"{"isWorkDay":true,"dailySchedule":[{"startDate":"2026-10-19T07:00:00.000Z","endDate":"2026-10-19T15:40:00.000Z","tradingSessionType":"MAIN","tradingSessionStatus":"OPEN"}]}"#,
149 );
150 let s = map_daily_schedule(
151 "x",
152 NaiveDate::from_ymd_opt(2026, 10, 19).unwrap(),
153 &r,
154 Instant::now(),
155 );
156 assert_eq!(s.sessions.len(), 1);
157 assert_eq!(s.sessions[0].kind, SessionKind::Main);
158 }
159}