use chrono::{FixedOffset, NaiveDate, NaiveDateTime, TimeZone, Utc};
use serde_json::Value;
use crate::core::types::{Kline, Ticker, OrderBook, OrderBookLevel};
pub type ParseResult<T> = Result<T, String>;
pub struct MoexParser;
impl MoexParser {
fn get_block<'a>(response: &'a Value, block_name: &str) -> ParseResult<&'a Value> {
response
.get(block_name)
.ok_or_else(|| format!("Missing '{}' block", block_name))
}
fn get_columns(block: &Value) -> ParseResult<&Vec<Value>> {
block
.get("columns")
.and_then(|v| v.as_array())
.ok_or_else(|| "Missing 'columns' array".to_string())
}
fn get_data(block: &Value) -> ParseResult<&Vec<Value>> {
block
.get("data")
.and_then(|v| v.as_array())
.ok_or_else(|| "Missing 'data' array".to_string())
}
fn find_column_index(columns: &[Value], name: &str) -> Option<usize> {
columns.iter().position(|col| col.as_str() == Some(name))
}
fn get_value<'a>(row: &'a Value, columns: &[Value], column: &str) -> Option<&'a Value> {
let row_array = row.as_array()?;
let index = Self::find_column_index(columns, column)?;
row_array.get(index)
}
fn parse_f64(value: &Value) -> Option<f64> {
value
.as_f64()
.or_else(|| value.as_str().and_then(|s| s.parse().ok()))
}
fn parse_timestamp(datetime_str: &str) -> Option<i64> {
let msk = FixedOffset::east_opt(3 * 3600)?;
if let Ok(ndt) = NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%d %H:%M:%S") {
let local = msk.from_local_datetime(&ndt).single()?;
return Some(local.with_timezone(&Utc).timestamp_millis());
}
if let Ok(nd) = NaiveDate::parse_from_str(datetime_str, "%Y-%m-%d") {
let ndt = nd.and_hms_opt(0, 0, 0)?;
let local = msk.from_local_datetime(&ndt).single()?;
return Some(local.with_timezone(&Utc).timestamp_millis());
}
None
}
pub fn parse_price(response: &Value) -> ParseResult<f64> {
let block = Self::get_block(response, "marketdata")?;
let columns = Self::get_columns(block)?;
let data = Self::get_data(block)?;
let first_row = data.first().ok_or("Empty data array")?;
let last_value = Self::get_value(first_row, columns, "LAST")
.ok_or("Missing 'LAST' column")?;
Self::parse_f64(last_value)
.ok_or_else(|| "Invalid LAST price value".to_string())
}
pub fn parse_ticker(response: &Value, _symbol: &str) -> ParseResult<Ticker> {
let marketdata = Self::get_block(response, "marketdata")?;
let columns = Self::get_columns(marketdata)?;
let data = Self::get_data(marketdata)?;
let row = data.first().ok_or("Empty marketdata")?;
let last_price = Self::get_value(row, columns, "LAST")
.and_then(Self::parse_f64)
.ok_or("Missing LAST price")?;
let bid_price = Self::get_value(row, columns, "BID")
.and_then(Self::parse_f64);
let ask_price = Self::get_value(row, columns, "ASK")
.and_then(Self::parse_f64);
let high_24h = Self::get_value(row, columns, "HIGH")
.and_then(Self::parse_f64);
let low_24h = Self::get_value(row, columns, "LOW")
.and_then(Self::parse_f64);
let volume_24h = Self::get_value(row, columns, "VOLUME")
.and_then(Self::parse_f64);
let value = Self::get_value(row, columns, "VALUE")
.and_then(Self::parse_f64);
let change = Self::get_value(row, columns, "LASTCHANGE")
.and_then(Self::parse_f64);
let change_pct = Self::get_value(row, columns, "LASTCHANGEPRCNT")
.and_then(Self::parse_f64);
let timestamp = Self::get_value(row, columns, "SYSTIME")
.or_else(|| Self::get_value(row, columns, "UPDATETIME"))
.and_then(|v| v.as_str())
.and_then(Self::parse_timestamp)
.unwrap_or_else(|| chrono::Utc::now().timestamp_millis());
Ok(Ticker {
last_price,
bid_price,
ask_price,
high_24h,
low_24h,
volume_24h,
quote_volume_24h: value,
price_change_24h: change,
price_change_percent_24h: change_pct,
timestamp,
})
}
pub fn parse_klines(response: &Value) -> ParseResult<Vec<Kline>> {
let block = Self::get_block(response, "candles")?;
let columns = Self::get_columns(block)?;
let data = Self::get_data(block)?;
data.iter()
.map(|row| {
let open = Self::get_value(row, columns, "open")
.and_then(Self::parse_f64)
.ok_or("Missing open")?;
let high = Self::get_value(row, columns, "high")
.and_then(Self::parse_f64)
.ok_or("Missing high")?;
let low = Self::get_value(row, columns, "low")
.and_then(Self::parse_f64)
.ok_or("Missing low")?;
let close = Self::get_value(row, columns, "close")
.and_then(Self::parse_f64)
.ok_or("Missing close")?;
let volume = Self::get_value(row, columns, "volume")
.and_then(Self::parse_f64)
.ok_or("Missing volume")?;
let quote_volume = Self::get_value(row, columns, "value")
.and_then(Self::parse_f64);
let open_time = Self::get_value(row, columns, "begin")
.and_then(|v| v.as_str())
.and_then(Self::parse_timestamp)
.ok_or("Missing begin timestamp")?;
let close_time = Self::get_value(row, columns, "end")
.and_then(|v| v.as_str())
.and_then(Self::parse_timestamp);
Ok(Kline {
open_time,
open,
high,
low,
close,
volume,
quote_volume,
close_time,
trades: None,
})
})
.collect()
}
pub fn parse_orderbook(response: &Value) -> ParseResult<OrderBook> {
let block = Self::get_block(response, "orderbook")?;
let columns = Self::get_columns(block)?;
let data = Self::get_data(block)?;
let mut bids = Vec::new();
let mut asks = Vec::new();
for row in data {
let side = Self::get_value(row, columns, "BUYSELL")
.and_then(|v| v.as_str());
let price = Self::get_value(row, columns, "PRICE")
.and_then(Self::parse_f64)
.ok_or("Missing price")?;
let quantity = Self::get_value(row, columns, "QUANTITY")
.and_then(Self::parse_f64)
.ok_or("Missing quantity")?;
match side {
Some("B") => bids.push(OrderBookLevel::new(price, quantity)),
Some("S") => asks.push(OrderBookLevel::new(price, quantity)),
_ => {}
}
}
bids.sort_by(|a, b| b.price.partial_cmp(&a.price).expect("f64 comparison should not return None"));
asks.sort_by(|a, b| a.price.partial_cmp(&b.price).expect("f64 comparison should not return None"));
let timestamp = chrono::Utc::now().timestamp_millis();
Ok(OrderBook {
bids,
asks,
timestamp,
sequence: None,
last_update_id: None,
first_update_id: None,
prev_update_id: None,
event_time: None,
transaction_time: None,
checksum: None,
})
}
pub fn parse_symbols(response: &Value) -> ParseResult<Vec<String>> {
let block = Self::get_block(response, "securities")?;
let columns = Self::get_columns(block)?;
let data = Self::get_data(block)?;
Ok(data
.iter()
.filter_map(|row| {
Self::get_value(row, columns, "SECID")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_price() {
let response = json!({
"marketdata": {
"columns": ["SECID", "LAST", "BID", "ASK"],
"data": [
["SBER", 306.75, 306.74, 306.76]
]
}
});
let price = MoexParser::parse_price(&response).unwrap();
assert_eq!(price, 306.75);
}
#[test]
fn test_parse_ticker() {
let response = json!({
"marketdata": {
"columns": ["SECID", "LAST", "BID", "ASK", "HIGH", "LOW", "VOLUME", "LASTCHANGE", "LASTCHANGEPRCNT", "SYSTIME"],
"data": [
["SBER", 306.75, 306.74, 306.76, 307.35, 305.12, 4800000, -0.13, -0.04, "2026-01-26 19:00:01"]
]
}
});
let ticker = MoexParser::parse_ticker(&response, "SBER").unwrap();
assert_eq!(ticker.last_price, 306.75);
assert_eq!(ticker.bid_price, Some(306.74));
assert_eq!(ticker.ask_price, Some(306.76));
}
#[test]
fn test_parse_symbols() {
let response = json!({
"securities": {
"columns": ["SECID", "SHORTNAME"],
"data": [
["SBER", "Сбербанк"],
["GAZP", "ГАЗПРОМ"],
["LKOH", "ЛУКОЙЛ"]
]
}
});
let symbols = MoexParser::parse_symbols(&response).unwrap();
assert_eq!(symbols, vec!["SBER", "GAZP", "LKOH"]);
}
}