1use brokapi_core::Decimal;
4use std::time::Instant;
5
6use brokapi_bcs_protocol::http::models::{CandlesResponse, LastTradeRecord, OrderBookResponse};
7use brokapi_bcs_protocol::ws::models::{
8 LastCandleData, LastTradesData, MarketDataMessage, OrderBookData, QuotesData,
9};
10use brokapi_core::{
11 BookLevel, Candle, Event, MarketTrade, OrderBook, Price, Qty, Quote, Side, Timeframe,
12};
13
14use super::{instrument_id_ticker, parse_broker_ts, timestamps};
15
16pub fn map_rest_candles(resp: &CandlesResponse, tf: Timeframe, now: Instant) -> Vec<Candle> {
22 let instrument = instrument_id_ticker(&resp.ticker, &resp.class_code);
23 resp.bars
24 .iter()
25 .map(|bar| {
26 let close_ts = super::unix_nanos(bar.time);
27 Candle {
28 instrument: instrument.clone(),
29 timeframe: tf,
30 open: Price::new(bar.open),
31 high: Price::new(bar.high),
32 low: Price::new(bar.low),
33 close: Price::new(bar.close),
34 volume: Qty::new(bar.volume),
35 open_ts: close_ts,
36 close_ts,
37 ts: timestamps(now, Some(close_ts)),
38 }
39 })
40 .collect()
41}
42
43pub fn map_rest_order_book(book: &OrderBookResponse, now: Instant) -> Event {
67 let instrument = instrument_id_ticker(&book.ticker, &book.class_code);
68 let broker_ts = parse_broker_ts(book.date_time.as_deref());
69 Event::OrderBook(map_order_book_snapshot(
70 &instrument,
71 &book.bids,
72 &book.asks,
73 timestamps(now, broker_ts),
74 ))
75}
76
77pub fn map_ws_market_data(
83 msg: &MarketDataMessage,
84 now: Instant,
85 sub: Option<brokapi_core::SubscriptionId>,
86) -> Option<MappedMarketEvent> {
87 let event = match msg {
88 MarketDataMessage::OrderBook(data) => Event::OrderBook(map_ws_order_book(data, now)),
89 MarketDataMessage::Quotes(data) => Event::Quote(map_quotes(data, now)),
90 MarketDataMessage::LastTrades(data) => Event::MarketTrade(map_last_trade(data, now)?),
91 MarketDataMessage::LastCandle(data) => map_last_candle(data, now)?,
92 MarketDataMessage::QuotesSuccess(_)
93 | MarketDataMessage::OrderBookSuccess(_)
94 | MarketDataMessage::LastTradesSuccess(_)
95 | MarketDataMessage::LastCandleSuccess(_)
96 | MarketDataMessage::ErrorResponse(_)
97 | MarketDataMessage::Undefined => return None,
98 };
99 Some(MappedMarketEvent { sub, event })
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct MappedMarketEvent {
105 pub sub: Option<brokapi_core::SubscriptionId>,
107 pub event: Event,
109}
110
111pub fn map_ws_order_book(data: &OrderBookData, now: Instant) -> OrderBook {
114 let instrument = instrument_id_ticker(&data.ticker, &data.class_code);
115 let broker_ts = parse_broker_ts(data.date_time.as_deref());
116 map_order_book_snapshot(
117 &instrument,
118 &data.bids,
119 &data.asks,
120 timestamps(now, broker_ts),
121 )
122}
123
124fn map_order_book_snapshot(
125 instrument: &brokapi_core::InstrumentId,
126 bids: &[impl BookLevelSource],
127 asks: &[impl BookLevelSource],
128 ts: brokapi_core::Timestamps,
129) -> OrderBook {
130 OrderBook::Snapshot {
131 instrument: instrument.clone(),
132 bids: bids
133 .iter()
134 .map(|l| BookLevel {
135 price: Price::new(l.price()),
136 qty: Qty::new(l.quantity()),
137 })
138 .collect(),
139 asks: asks
140 .iter()
141 .map(|l| BookLevel {
142 price: Price::new(l.price()),
143 qty: Qty::new(l.quantity()),
144 })
145 .collect(),
146 ts,
147 }
148}
149
150pub trait BookLevelSource {
152 fn price(&self) -> Decimal;
154 fn quantity(&self) -> Decimal;
156}
157
158impl BookLevelSource for brokapi_bcs_protocol::http::models::OrderBookLevel {
159 fn price(&self) -> Decimal {
160 self.price
161 }
162 fn quantity(&self) -> Decimal {
163 self.quantity
164 }
165}
166
167impl BookLevelSource for brokapi_bcs_protocol::ws::models::OrderBookLevel {
168 fn price(&self) -> Decimal {
169 self.price
170 }
171 fn quantity(&self) -> Decimal {
172 self.quantity
173 }
174}
175
176pub fn map_quotes(data: &QuotesData, now: Instant) -> Quote {
182 let instrument = instrument_id_ticker(&data.ticker, &data.class_code);
183 let broker_ts = parse_broker_ts(data.date_time.as_deref());
184 Quote {
185 instrument,
186 bid: data.bid.map(|p| BookLevel {
187 price: Price::new(p),
188 qty: Qty::new(data.volume.unwrap_or(Decimal::ZERO)),
189 }),
190 ask: data.ask.map(|p| BookLevel {
191 price: Price::new(p),
192 qty: Qty::new(data.volume.unwrap_or(Decimal::ZERO)),
193 }),
194 ts: timestamps(now, broker_ts),
195 }
196}
197
198pub fn map_last_trade(data: &LastTradesData, now: Instant) -> Option<MarketTrade> {
204 let instrument = instrument_id_ticker(&data.ticker, &data.class_code);
205 let side = match data.side.as_deref() {
206 Some("1") | Some("BUY") => Side::Buy,
207 Some("2") | Some("SELL") => Side::Sell,
208 _ => return None,
209 };
210 let broker_ts = parse_broker_ts(data.date_time.as_deref());
211 Some(MarketTrade {
212 instrument,
213 price: Price::new(data.price),
214 qty: Qty::new(data.quantity),
215 side,
216 trade_id: None,
217 ts: timestamps(now, broker_ts),
218 })
219}
220
221pub fn map_rest_last_trade(
225 trade: &LastTradeRecord,
226 instrument: &brokapi_core::InstrumentId,
227 now: Instant,
228) -> Option<MarketTrade> {
229 let side = match trade.side.as_deref() {
230 Some("1") | Some("BUY") => Side::Buy,
231 Some("2") | Some("SELL") => Side::Sell,
232 _ => return None,
233 };
234 let broker_ts = parse_broker_ts(trade.date_time.as_deref());
235 Some(MarketTrade {
236 instrument: instrument.clone(),
237 price: Price::new(trade.price),
238 qty: Qty::new(trade.quantity),
239 side,
240 trade_id: None,
241 ts: timestamps(now, broker_ts),
242 })
243}
244
245fn map_last_candle(data: &LastCandleData, now: Instant) -> Option<Event> {
246 let ticker = data.ticker.as_deref()?;
247 let class_code = data.class_code.as_deref()?;
248 let instrument = instrument_id_ticker(ticker, class_code);
249 let timeframe = data
250 .time_frame
251 .as_deref()
252 .and_then(parse_timeframe)
253 .unwrap_or(Timeframe::M1);
254 let broker_ts = parse_broker_ts(data.timestamp.as_deref());
255 let close_ts = broker_ts.unwrap_or_else(|| timestamps(now, None).rx_ts);
256 Some(Event::Candle(brokapi_core::Candle {
257 instrument,
258 timeframe,
259 open: Price::new(data.open?),
260 high: Price::new(data.high?),
261 low: Price::new(data.low?),
262 close: Price::new(data.close?),
263 volume: Qty::new(data.volume.unwrap_or(Decimal::ZERO)),
264 open_ts: close_ts,
265 close_ts,
266 ts: timestamps(now, broker_ts),
267 }))
268}
269
270fn parse_timeframe(raw: &str) -> Option<Timeframe> {
271 match raw {
272 "M1" => Some(Timeframe::M1),
273 "M5" => Some(Timeframe::M5),
274 "M10" => Some(Timeframe::M10),
275 "M15" => Some(Timeframe::M15),
276 "M30" => Some(Timeframe::M30),
277 "H1" => Some(Timeframe::H1),
278 "H4" => Some(Timeframe::H4),
279 "D" | "D1" => Some(Timeframe::D1),
281 "W" | "W1" => Some(Timeframe::W1),
282 "MN" => Some(Timeframe::MN),
283 _ => None,
284 }
285}
286
287pub fn parse_subscribe_error(text: &str) -> Option<String> {
302 let v: serde_json::Value = serde_json::from_str(text).ok()?;
303 let errors = v.get("errors")?.as_array()?;
304 let first = errors.first()?;
305 Some(
306 first
307 .get("message")
308 .and_then(|m| m.as_str())
309 .unwrap_or("subscription failed")
310 .to_string(),
311 )
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 fn trade(side: Option<&str>) -> LastTradesData {
319 serde_json::from_value(serde_json::json!({
320 "ticker": "SBER", "classCode": "TQBR", "price": 100.5, "quantity": 3.0, "side": side
321 }))
322 .unwrap()
323 }
324
325 #[test]
326 fn stream_trade_side_is_read_and_never_guessed() {
327 let now = Instant::now();
328 assert_eq!(
329 map_last_trade(&trade(Some("BUY")), now).unwrap().side,
330 Side::Buy
331 );
332 assert_eq!(
333 map_last_trade(&trade(Some("2")), now).unwrap().side,
334 Side::Sell
335 );
336 assert!(map_last_trade(&trade(None), now).is_none());
337 assert!(map_last_trade(&trade(Some("???")), now).is_none());
338 }
339}