Skip to main content

brokapi_bcs/map/
schedule.rs

1//! Schedule mapping.
2//!
3//! `GET /trading-schedule/daily-schedule` (docs, checked 2026-10-19) takes `classCode` AND `ticker`
4//! (both required) and answers `isWorkDay` and `dailySchedule[]`, where `startDate` / `endDate` are
5//! TIMES OF DAY (`"14:30:00"`, the doc example), `tradingSessionType` is a Russian name (`Торговый
6//! период`, `Вечерняя торговая сессия`, `Аукцион открытия`, `Аукцион закрытия`, `Технический перерыв
7//! QUIK`, `Не рабочее время/Выходной/Праздник`) and `tradingSessionStatus` is `OPEN` or `CLOSE`.
8//!
9//! The answer is for ONE day (today), so the intervals are put on the day of `range.from`. The time
10//! zone of the times is not stated: they are read as Moscow time (UTC+3), which is an assumption. A full datetime (the form older samples had) is read as it is.
11
12use 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
20/// Seconds east of UTC of the exchange clock the times are read in (Moscow, no DST).
21const MSK_OFFSET_SECS: i64 = 3 * 3600;
22
23/// Maps the daily schedule of an instrument to a [`Schedule`] for `exchange` (the label the caller
24/// used, `"SBER@TQBR"`).
25///
26/// Only intervals with the status `OPEN` are kept, and a day that is not a working day gives no
27/// intervals at all. The times of day are put on `day` and read as Moscow time, which is an assumption
28/// (see the module docs).
29pub fn map_daily_schedule(
30    exchange: &str,
31    day: NaiveDate,
32    resp: &DailyScheduleResponse,
33    now: Instant,
34) -> Schedule {
35    // A day that is not a working day has no trading intervals, whatever the list says.
36    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        // 10:00 Moscow = 07:00 UTC on 2026-10-19
125        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}