Skip to main content

digdigdig3/l2/free/moex/
parser.rs

1//! # MOEX ISS Response Parsers
2//!
3//! Parse JSON responses from MOEX ISS API to domain types.
4//!
5//! ## MOEX Response Structure
6//! MOEX ISS has a unique response format with named sections (blocks):
7//! - Each response contains multiple named sections
8//! - Each section has: `metadata`, `columns`, `data`
9//! - Data is array of arrays (not objects)
10//! - Column order matters - use `columns` array to map values
11//!
12//! Example:
13//! ```json
14//! {
15//!   "securities": {
16//!     "metadata": {...},
17//!     "columns": ["SECID", "SHORTNAME", "LAST"],
18//!     "data": [
19//!       ["SBER", "Сбербанк", 306.75],
20//!       ["GAZP", "ГАЗПРОМ", 129.0]
21//!     ]
22//!   },
23//!   "marketdata": {
24//!     "columns": ["SECID", "BID", "ASK"],
25//!     "data": [...]
26//!   }
27//! }
28//! ```
29
30use chrono::{FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
31use serde_json::Value;
32use crate::core::types::{Kline, Ticker, OrderBook, OrderBookLevel};
33
34/// Result type for parsing operations
35pub type ParseResult<T> = Result<T, String>;
36
37/// MOEX ISS response parser
38pub struct MoexParser;
39
40impl MoexParser {
41    // ═══════════════════════════════════════════════════════════════════════════
42    // CORE HELPER METHODS
43    // ═══════════════════════════════════════════════════════════════════════════
44
45    /// Extract a named block from MOEX response
46    ///
47    /// MOEX responses have structure: `{ "blockname": { "columns": [...], "data": [...] } }`
48    fn get_block<'a>(response: &'a Value, block_name: &str) -> ParseResult<&'a Value> {
49        response
50            .get(block_name)
51            .ok_or_else(|| format!("Missing '{}' block", block_name))
52    }
53
54    /// Get columns array from a block
55    fn get_columns(block: &Value) -> ParseResult<&Vec<Value>> {
56        block
57            .get("columns")
58            .and_then(|v| v.as_array())
59            .ok_or_else(|| "Missing 'columns' array".to_string())
60    }
61
62    /// Get data array from a block
63    fn get_data(block: &Value) -> ParseResult<&Vec<Value>> {
64        block
65            .get("data")
66            .and_then(|v| v.as_array())
67            .ok_or_else(|| "Missing 'data' array".to_string())
68    }
69
70    /// Find column index by name
71    fn find_column_index(columns: &[Value], name: &str) -> Option<usize> {
72        columns.iter().position(|col| col.as_str() == Some(name))
73    }
74
75    /// Get value from row by column name
76    fn get_value<'a>(row: &'a Value, columns: &[Value], column: &str) -> Option<&'a Value> {
77        let row_array = row.as_array()?;
78        let index = Self::find_column_index(columns, column)?;
79        row_array.get(index)
80    }
81
82    /// Parse f64 from value (handles both number and string)
83    fn parse_f64(value: &Value) -> Option<f64> {
84        value
85            .as_f64()
86            .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
87    }
88
89    /// Parse timestamp from MOEX datetime string
90    ///
91    /// MOEX formats (always Moscow local time = UTC+3, no TZ suffix):
92    /// - DateTime: "2026-01-26 19:00:01"
93    /// - Date only: "2026-01-26" (interpreted as midnight MSK)
94    ///
95    /// Returns Unix ms in UTC. Earlier versions treated the naive string as
96    /// UTC, producing timestamps 3 hours in the future (the strict e2e_smoke
97    /// inspector flagged this as `ts_future_bug(timezone?)`).
98    fn parse_timestamp(datetime_str: &str) -> Option<i64> {
99        let msk = FixedOffset::east_opt(3 * 3600)?;
100
101        // Try full datetime first: "YYYY-MM-DD HH:MM:SS"
102        if let Ok(ndt) = NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S") {
103            // Interpret naive as Moscow local, then convert to UTC ms.
104            let local = msk.from_local_datetime(&ndt).single()?;
105            return Some(local.with_timezone(&Utc).timestamp_millis());
106        }
107        // Fall back to date only: "YYYY-MM-DD" → midnight MSK
108        if let Ok(nd) = NaiveDate::parse_from_str(datetime_str, "%Y-%m-%d") {
109            let ndt = nd.and_hms_opt(0, 0, 0)?;
110            let local = msk.from_local_datetime(&ndt).single()?;
111            return Some(local.with_timezone(&Utc).timestamp_millis());
112        }
113        None
114    }
115
116    // ═══════════════════════════════════════════════════════════════════════════
117    // PRICE PARSING
118    // ═══════════════════════════════════════════════════════════════════════════
119
120    /// Parse current price from MOEX response
121    ///
122    /// Expected block: "marketdata"
123    /// Required column: "LAST"
124    pub fn parse_price(response: &Value) -> ParseResult<f64> {
125        let block = Self::get_block(response, "marketdata")?;
126        let columns = Self::get_columns(block)?;
127        let data = Self::get_data(block)?;
128
129        let first_row = data.first().ok_or("Empty data array")?;
130        let last_value = Self::get_value(first_row, columns, "LAST")
131            .ok_or("Missing 'LAST' column")?;
132
133        Self::parse_f64(last_value)
134            .ok_or_else(|| "Invalid LAST price value".to_string())
135    }
136
137    // ═══════════════════════════════════════════════════════════════════════════
138    // TICKER PARSING
139    // ═══════════════════════════════════════════════════════════════════════════
140
141    /// Parse ticker from MOEX response
142    ///
143    /// Expected blocks:
144    /// - "securities" - for symbol info
145    /// - "marketdata" - for price and volume data
146    pub fn parse_ticker(response: &Value, _symbol: &str) -> ParseResult<Ticker> {
147        let marketdata = Self::get_block(response, "marketdata")?;
148        let columns = Self::get_columns(marketdata)?;
149        let data = Self::get_data(marketdata)?;
150
151        let row = data.first().ok_or("Empty marketdata")?;
152
153        // Extract values with safe fallbacks
154        let last_price = Self::get_value(row, columns, "LAST")
155            .and_then(Self::parse_f64)
156            .ok_or("Missing LAST price")?;
157
158        let bid_price = Self::get_value(row, columns, "BID")
159            .and_then(Self::parse_f64);
160
161        let ask_price = Self::get_value(row, columns, "ASK")
162            .and_then(Self::parse_f64);
163
164        let high_24h = Self::get_value(row, columns, "HIGH")
165            .and_then(Self::parse_f64);
166
167        let low_24h = Self::get_value(row, columns, "LOW")
168            .and_then(Self::parse_f64);
169
170        // MOEX returns VOLUME as either an integer (shares for stocks) or a
171        // floating-point value (futures contracts). Prefer parse_f64 — it
172        // already handles both Number variants — and treat a missing field
173        // as None, not zero. Outside trading hours MOEX may legitimately
174        // report VOLUME=0; that propagates as Some(0.0), which is correct.
175        let volume_24h = Self::get_value(row, columns, "VOLUME")
176            .and_then(Self::parse_f64);
177
178        let value = Self::get_value(row, columns, "VALUE")
179            .and_then(Self::parse_f64);
180
181        let change = Self::get_value(row, columns, "LASTCHANGE")
182            .and_then(Self::parse_f64);
183
184        let change_pct = Self::get_value(row, columns, "LASTCHANGEPRCNT")
185            .and_then(Self::parse_f64);
186
187        // Parse timestamp from SYSTIME or UPDATETIME
188        let timestamp = Self::get_value(row, columns, "SYSTIME")
189            .or_else(|| Self::get_value(row, columns, "UPDATETIME"))
190            .and_then(|v| v.as_str())
191            .and_then(Self::parse_timestamp)
192            .unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
193
194        Ok(Ticker {
195            last_price,
196            bid_price,
197            ask_price,
198            high_24h,
199            low_24h,
200            volume_24h,
201            quote_volume_24h: value,
202            price_change_24h: change,
203            price_change_percent_24h: change_pct,
204            timestamp,
205        })
206    }
207
208    // ═══════════════════════════════════════════════════════════════════════════
209    // KLINE/CANDLE PARSING
210    // ═══════════════════════════════════════════════════════════════════════════
211
212    /// Parse klines/candles from MOEX response
213    ///
214    /// Expected block: "candles"
215    /// Required columns: "open", "close", "high", "low", "volume", "begin", "end"
216    pub fn parse_klines(response: &Value) -> ParseResult<Vec<Kline>> {
217        let block = Self::get_block(response, "candles")?;
218        let columns = Self::get_columns(block)?;
219        let data = Self::get_data(block)?;
220
221        data.iter()
222            .map(|row| {
223                let open = Self::get_value(row, columns, "open")
224                    .and_then(Self::parse_f64)
225                    .ok_or("Missing open")?;
226
227                let high = Self::get_value(row, columns, "high")
228                    .and_then(Self::parse_f64)
229                    .ok_or("Missing high")?;
230
231                let low = Self::get_value(row, columns, "low")
232                    .and_then(Self::parse_f64)
233                    .ok_or("Missing low")?;
234
235                let close = Self::get_value(row, columns, "close")
236                    .and_then(Self::parse_f64)
237                    .ok_or("Missing close")?;
238
239                let volume = Self::get_value(row, columns, "volume")
240                    .and_then(Self::parse_f64)
241                    .ok_or("Missing volume")?;
242
243                let quote_volume = Self::get_value(row, columns, "value")
244                    .and_then(Self::parse_f64);
245
246                // Parse begin timestamp
247                let open_time = Self::get_value(row, columns, "begin")
248                    .and_then(|v| v.as_str())
249                    .and_then(Self::parse_timestamp)
250                    .ok_or("Missing begin timestamp")?;
251
252                // Parse end timestamp
253                let close_time = Self::get_value(row, columns, "end")
254                    .and_then(|v| v.as_str())
255                    .and_then(Self::parse_timestamp);
256
257                Ok(Kline {
258                    open_time,
259                    open,
260                    high,
261                    low,
262                    close,
263                    volume,
264                    quote_volume,
265                    close_time,
266                    trades: None,
267                })
268            })
269            .collect()
270    }
271
272    // ═══════════════════════════════════════════════════════════════════════════
273    // ORDERBOOK PARSING
274    // ═══════════════════════════════════════════════════════════════════════════
275
276    /// Parse orderbook from MOEX response
277    ///
278    /// Note: Orderbook requires paid subscription.
279    /// Expected block: "orderbook"
280    pub fn parse_orderbook(response: &Value) -> ParseResult<OrderBook> {
281        let block = Self::get_block(response, "orderbook")?;
282        let columns = Self::get_columns(block)?;
283        let data = Self::get_data(block)?;
284
285        // MOEX orderbook structure may vary
286        // This is a placeholder implementation
287        let mut bids = Vec::new();
288        let mut asks = Vec::new();
289
290        for row in data {
291            let side = Self::get_value(row, columns, "BUYSELL")
292                .and_then(|v| v.as_str());
293
294            let price = Self::get_value(row, columns, "PRICE")
295                .and_then(Self::parse_f64)
296                .ok_or("Missing price")?;
297
298            let quantity = Self::get_value(row, columns, "QUANTITY")
299                .and_then(Self::parse_f64)
300                .ok_or("Missing quantity")?;
301
302            match side {
303                Some("B") => bids.push(OrderBookLevel::new(price, quantity)),
304                Some("S") => asks.push(OrderBookLevel::new(price, quantity)),
305                _ => {}
306            }
307        }
308
309        // Sort: bids descending, asks ascending
310        bids.sort_by(|a, b| b.price.partial_cmp(&a.price).expect("f64 comparison should not return None"));
311        asks.sort_by(|a, b| a.price.partial_cmp(&b.price).expect("f64 comparison should not return None"));
312
313        let timestamp = chrono::Utc::now().timestamp_millis();
314
315        Ok(OrderBook {
316            bids,
317            asks,
318            timestamp,
319            sequence: None,
320            last_update_id: None,
321            first_update_id: None,
322            prev_update_id: None,
323            event_time: None,
324            transaction_time: None,
325            checksum: None,
326        })
327    }
328
329    // ═══════════════════════════════════════════════════════════════════════════
330    // SYMBOLS PARSING
331    // ═══════════════════════════════════════════════════════════════════════════
332
333    /// Parse symbols list from MOEX response
334    ///
335    /// Expected block: "securities"
336    /// Required column: "SECID"
337    pub fn parse_symbols(response: &Value) -> ParseResult<Vec<String>> {
338        let block = Self::get_block(response, "securities")?;
339        let columns = Self::get_columns(block)?;
340        let data = Self::get_data(block)?;
341
342        Ok(data
343            .iter()
344            .filter_map(|row| {
345                Self::get_value(row, columns, "SECID")
346                    .and_then(|v| v.as_str())
347                    .map(|s| s.to_string())
348            })
349            .collect())
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use serde_json::json;
357
358    #[test]
359    fn test_parse_price() {
360        let response = json!({
361            "marketdata": {
362                "columns": ["SECID", "LAST", "BID", "ASK"],
363                "data": [
364                    ["SBER", 306.75, 306.74, 306.76]
365                ]
366            }
367        });
368
369        let price = MoexParser::parse_price(&response).unwrap();
370        assert_eq!(price, 306.75);
371    }
372
373    #[test]
374    fn test_parse_ticker() {
375        let response = json!({
376            "marketdata": {
377                "columns": ["SECID", "LAST", "BID", "ASK", "HIGH", "LOW", "VOLUME", "LASTCHANGE", "LASTCHANGEPRCNT", "SYSTIME"],
378                "data": [
379                    ["SBER", 306.75, 306.74, 306.76, 307.35, 305.12, 4800000, -0.13, -0.04, "2026-01-26 19:00:01"]
380                ]
381            }
382        });
383
384        let ticker = MoexParser::parse_ticker(&response, "SBER").unwrap();
385        assert_eq!(ticker.last_price, 306.75);
386        assert_eq!(ticker.bid_price, Some(306.74));
387        assert_eq!(ticker.ask_price, Some(306.76));
388    }
389
390    #[test]
391    fn test_parse_symbols() {
392        let response = json!({
393            "securities": {
394                "columns": ["SECID", "SHORTNAME"],
395                "data": [
396                    ["SBER", "Сбербанк"],
397                    ["GAZP", "ГАЗПРОМ"],
398                    ["LKOH", "ЛУКОЙЛ"]
399                ]
400            }
401        });
402
403        let symbols = MoexParser::parse_symbols(&response).unwrap();
404        assert_eq!(symbols, vec!["SBER", "GAZP", "LKOH"]);
405    }
406}