1use std::collections::{HashMap, VecDeque};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use brokapi_bcs_protocol::models::instrument::InstrumentKey as BcsInstrumentKey;
8use brokapi_bcs_protocol::models::messages::IncomingMessage;
9use brokapi_bcs_protocol::ws::client::BcsWsChannel;
10use brokapi_bcs_protocol::ws::models::{MarketDataMessage, MarketDataSubscribe};
11use brokapi_bcs_protocol::ws::parse_incoming;
12use brokapi_core::{Event, ResyncReason, SubscriptionId, Timeframe};
13use brokapi_sansio::{
14 BrokerCore, CoreEvent, DesiredSubscription, EventSink, Outbound, SubscriptionKind,
15 SubscriptionSet,
16};
17use tokio::sync::Notify;
18
19use crate::map::market::{map_ws_market_data, parse_subscribe_error};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SubState {
24 Pending,
28 Active,
30 Failed,
33}
34
35#[derive(Debug)]
75pub struct MdCore {
76 desired: SubscriptionSet,
77 actual: HashMap<SubscriptionId, SubState>,
78 sub_by_id: HashMap<SubscriptionId, DesiredSubscription>,
79 outbound: VecDeque<Outbound>,
80 book_seq: HashMap<SubscriptionId, u64>,
81 heartbeat_deadline: Option<Instant>,
82 connections: u32,
84 failures: HashMap<SubscriptionId, String>,
86 ack: Arc<Notify>,
89}
90
91impl Default for MdCore {
92 fn default() -> Self {
93 Self::new()
94 }
95}
96
97impl MdCore {
98 pub fn new() -> Self {
100 Self {
101 desired: SubscriptionSet::new(),
102 actual: HashMap::new(),
103 sub_by_id: HashMap::new(),
104 outbound: VecDeque::new(),
105 book_seq: HashMap::new(),
106 heartbeat_deadline: None,
107 connections: 0,
108 failures: HashMap::new(),
109 ack: Arc::new(Notify::new()),
110 }
111 }
112
113 pub fn notifier(&self) -> Arc<Notify> {
116 Arc::clone(&self.ack)
117 }
118
119 pub fn failure(&self, id: SubscriptionId) -> Option<&str> {
121 self.failures.get(&id).map(String::as_str)
122 }
123
124 pub fn desired(&self) -> &SubscriptionSet {
126 &self.desired
127 }
128
129 pub fn actual_state(&self, id: SubscriptionId) -> Option<SubState> {
132 self.actual.get(&id).copied()
133 }
134
135 fn on_reconnected(&mut self, out: &mut EventSink) {
140 self.connections += 1;
141 if self.connections < 2 {
142 return;
143 }
144 for sub in self.desired.subs() {
145 if self.actual.contains_key(&sub.id) {
146 self.actual.insert(sub.id, SubState::Pending);
147 }
148 if let SubscriptionKind::OrderBook { instrument, .. } = &sub.kind {
149 self.book_seq.remove(&sub.id);
150 out.push(CoreEvent {
151 sub: Some(sub.id),
152 payload: Event::Resync {
153 sub: sub.id,
154 instrument: instrument.clone(),
155 reason: ResyncReason::SubscriptionReset,
156 },
157 });
158 }
159 }
160 }
161
162 fn queue_subscribe(&mut self, sub: &DesiredSubscription) {
163 let msg = match &sub.kind {
164 SubscriptionKind::OrderBook { instrument, depth } => {
165 let key = bcs_key(instrument);
166 MarketDataSubscribe::order_book(vec![key], *depth)
167 }
168 SubscriptionKind::Quotes { instrument } => {
169 MarketDataSubscribe::quotes(vec![bcs_key(instrument)])
170 }
171 SubscriptionKind::LastTrades { instrument } => {
172 MarketDataSubscribe::last_trades(vec![bcs_key(instrument)])
173 }
174 SubscriptionKind::LastCandle {
175 instrument,
176 timeframe,
177 } => MarketDataSubscribe::last_candle(
178 vec![bcs_key(instrument)],
179 timeframe_label(*timeframe),
180 ),
181 SubscriptionKind::OrderState
182 | SubscriptionKind::Portfolio
183 | SubscriptionKind::Executions => return,
184 };
185 self.outbound
186 .push_back(Outbound::WsText(serde_json::to_string(&msg).unwrap()));
187 self.actual.insert(sub.id, SubState::Pending);
188 self.sub_by_id.insert(sub.id, sub.clone());
189 }
190
191 fn queue_unsubscribe(&mut self, sub: &DesiredSubscription) {
192 let msg = match &sub.kind {
193 SubscriptionKind::OrderBook { instrument, depth } => {
194 MarketDataSubscribe::unsubscribe_order_book(vec![bcs_key(instrument)], *depth)
195 }
196 SubscriptionKind::Quotes { instrument } => {
197 MarketDataSubscribe::unsubscribe_quotes(vec![bcs_key(instrument)])
198 }
199 SubscriptionKind::LastTrades { instrument } => {
200 MarketDataSubscribe::unsubscribe_last_trades(vec![bcs_key(instrument)])
201 }
202 SubscriptionKind::LastCandle {
203 instrument,
204 timeframe,
205 } => MarketDataSubscribe::unsubscribe_last_candle(
206 vec![bcs_key(instrument)],
207 timeframe_label(*timeframe),
208 ),
209 SubscriptionKind::OrderState
210 | SubscriptionKind::Portfolio
211 | SubscriptionKind::Executions => return,
212 };
213 self.outbound
214 .push_back(Outbound::WsText(serde_json::to_string(&msg).unwrap()));
215 self.actual.remove(&sub.id);
216 self.failures.remove(&sub.id);
217 self.sub_by_id.remove(&sub.id);
218 self.book_seq.remove(&sub.id);
219 }
220
221 fn handle_market_data(&mut self, md: MarketDataMessage, now: Instant, out: &mut EventSink) {
222 match &md {
223 MarketDataMessage::OrderBookSuccess(_)
224 | MarketDataMessage::QuotesSuccess(_)
225 | MarketDataMessage::LastTradesSuccess(_)
226 | MarketDataMessage::LastCandleSuccess(_) => {
227 if let Some(sub_id) = self.match_success(&md) {
228 self.actual.insert(sub_id, SubState::Active);
229 self.ack.notify_waiters();
230 }
231 return;
232 }
233 MarketDataMessage::ErrorResponse(err) => {
234 let reason = err
235 .errors
236 .first()
237 .map(|e| e.message.clone())
238 .or_else(|| err.error.as_ref().map(|e| e.message.clone()))
239 .unwrap_or_else(|| "subscription error".into());
240 self.fail_pending(&reason);
241 return;
242 }
243 _ => {}
244 }
245
246 if let Some(mapped) = map_ws_market_data(&md, now, None) {
247 let sub_id = self.match_data_sub(&md);
248 if let Some(id) = sub_id
250 && self.actual.get(&id) == Some(&SubState::Pending)
251 {
252 self.actual.insert(id, SubState::Active);
253 self.ack.notify_waiters();
254 }
255 let payload = mapped.event;
256 if let (Some(sub), Event::OrderBook(_)) = (sub_id, &payload) {
257 let seq = self.book_seq.entry(sub).or_insert(0);
258 *seq += 1;
259 }
260 out.push(CoreEvent {
261 sub: sub_id.or(mapped.sub),
262 payload,
263 });
264 }
265 }
266
267 fn match_success(&self, md: &MarketDataMessage) -> Option<SubscriptionId> {
268 for sub in self.desired.subs() {
269 if self.actual.get(&sub.id) != Some(&SubState::Pending) {
270 continue;
271 }
272 let matches = matches!(
273 (&sub.kind, md),
274 (
275 SubscriptionKind::OrderBook { .. },
276 MarketDataMessage::OrderBookSuccess(_)
277 ) | (
278 SubscriptionKind::Quotes { .. },
279 MarketDataMessage::QuotesSuccess(_)
280 ) | (
281 SubscriptionKind::LastTrades { .. },
282 MarketDataMessage::LastTradesSuccess(_)
283 ) | (
284 SubscriptionKind::LastCandle { .. },
285 MarketDataMessage::LastCandleSuccess(_)
286 )
287 );
288 if matches {
289 return Some(sub.id);
290 }
291 }
292 None
293 }
294
295 fn match_data_sub(&self, md: &MarketDataMessage) -> Option<SubscriptionId> {
296 for sub in self.desired.subs() {
297 if !matches!(
300 self.actual.get(&sub.id),
301 Some(SubState::Active | SubState::Pending)
302 ) {
303 continue;
304 }
305 let matches = match (&sub.kind, md) {
306 (
307 SubscriptionKind::OrderBook { instrument, .. },
308 MarketDataMessage::OrderBook(d),
309 ) => same_instrument(instrument, &d.ticker, &d.class_code),
310 (SubscriptionKind::Quotes { instrument }, MarketDataMessage::Quotes(d)) => {
311 same_instrument(instrument, &d.ticker, &d.class_code)
312 }
313 (SubscriptionKind::LastTrades { instrument }, MarketDataMessage::LastTrades(d)) => {
314 same_instrument(instrument, &d.ticker, &d.class_code)
315 }
316 (
317 SubscriptionKind::LastCandle { instrument, .. },
318 MarketDataMessage::LastCandle(d),
319 ) => d
320 .ticker
321 .as_deref()
322 .zip(d.class_code.as_deref())
323 .is_some_and(|(t, c)| same_instrument(instrument, t, c)),
324 _ => false,
325 };
326 if matches {
327 return Some(sub.id);
328 }
329 }
330 None
331 }
332
333 fn fail_pending(&mut self, reason: &str) {
338 let pending: Vec<SubscriptionId> = self
339 .actual
340 .iter()
341 .filter(|(_, st)| **st == SubState::Pending)
342 .map(|(id, _)| *id)
343 .collect();
344 let text = reason.to_uppercase();
345 let mentioned: Vec<SubscriptionId> = self
346 .desired
347 .subs()
348 .iter()
349 .filter(|s| pending.contains(&s.id))
350 .filter(|s| {
351 let symbol = match &s.kind {
352 SubscriptionKind::OrderBook { instrument, .. }
353 | SubscriptionKind::Quotes { instrument }
354 | SubscriptionKind::LastTrades { instrument }
355 | SubscriptionKind::LastCandle { instrument, .. } => &instrument.key.symbol,
356 _ => return false,
357 };
358 !symbol.is_empty() && text.contains(&symbol.to_uppercase())
359 })
360 .map(|s| s.id)
361 .collect();
362 let failed: Vec<SubscriptionId> = if pending.len() == 1 || mentioned.is_empty() {
363 pending
364 } else {
365 mentioned
366 };
367 for id in failed {
368 self.actual.insert(id, SubState::Failed);
369 self.failures.insert(id, reason.to_string());
370 }
371 self.ack.notify_waiters();
372 }
373
374 pub fn feed_raw(&mut self, text: &str, now: Instant, out: &mut EventSink) {
382 if let Some(reason) = parse_subscribe_error(text) {
383 self.fail_pending(&reason);
384 return;
385 }
386 let frame = parse_incoming(text, BcsWsChannel::MarketData);
387 self.on_frame(frame, now, out);
388 }
389}
390
391impl BrokerCore for MdCore {
392 type Frame = IncomingMessage;
393
394 fn on_frame(&mut self, frame: Self::Frame, now: Instant, out: &mut EventSink) {
395 self.heartbeat_deadline = Some(now + Duration::from_secs(30));
396 match frame {
397 IncomingMessage::MarketData(md) => self.handle_market_data(md, now, out),
398 IncomingMessage::Error(err) => self.fail_pending(&err.message),
399 IncomingMessage::Unknown(text) => {
402 if let Some(reason) = parse_subscribe_error(&text) {
403 self.fail_pending(&reason);
404 }
405 }
406 IncomingMessage::Reconnected => self.on_reconnected(out),
407 _ => {}
408 }
409 }
410
411 fn poll_transmit(&mut self) -> Option<Outbound> {
412 self.outbound.pop_front()
413 }
414
415 fn poll_timeout(&self) -> Option<Instant> {
416 self.heartbeat_deadline
417 }
418
419 fn on_timeout(&mut self, _now: Instant, _out: &mut EventSink) {
420 self.heartbeat_deadline = None;
421 }
422
423 fn apply(&mut self, desired: &SubscriptionSet) {
424 let old_ids: Vec<_> = self.desired.subs().iter().map(|s| s.id).collect();
425 let new_ids: Vec<_> = desired.subs().iter().map(|s| s.id).collect();
426
427 let to_unsub: Vec<_> = self
428 .desired
429 .subs()
430 .iter()
431 .filter(|sub| !new_ids.contains(&sub.id))
432 .cloned()
433 .collect();
434 for sub in to_unsub {
435 self.queue_unsubscribe(&sub);
436 }
437
438 self.desired = desired.clone();
439 self.sub_by_id.clear();
440 for sub in self.desired.subs() {
441 self.sub_by_id.insert(sub.id, sub.clone());
442 }
443
444 let to_sub: Vec<_> = self
445 .desired
446 .subs()
447 .iter()
448 .filter(|sub| {
449 !old_ids.contains(&sub.id) || self.actual.get(&sub.id) == Some(&SubState::Failed)
450 })
451 .cloned()
452 .collect();
453 for sub in to_sub {
454 self.queue_subscribe(&sub);
455 }
456 }
457}
458
459fn bcs_key(instrument: &brokapi_core::InstrumentId) -> BcsInstrumentKey {
460 match &instrument.native {
461 brokapi_core::NativeId::TickerClass { ticker, class_code } => {
462 BcsInstrumentKey::new(ticker.clone(), class_code.clone())
463 }
464 _ => BcsInstrumentKey::new(instrument.key.symbol.clone(), instrument.key.mic.clone()),
465 }
466}
467
468fn same_instrument(
469 instrument: &brokapi_core::InstrumentId,
470 ticker: &str,
471 class_code: &str,
472) -> bool {
473 match &instrument.native {
474 brokapi_core::NativeId::TickerClass {
475 ticker: t,
476 class_code: c,
477 } => t == ticker && c == class_code,
478 _ => instrument.key.symbol == ticker && instrument.key.mic == class_code,
479 }
480}
481
482fn timeframe_label(tf: Timeframe) -> &'static str {
483 match tf {
484 Timeframe::M1 => "M1",
485 Timeframe::M5 => "M5",
486 Timeframe::M10 => "M10",
488 Timeframe::M15 => "M15",
489 Timeframe::M30 => "M30",
490 Timeframe::H1 => "H1",
491 Timeframe::H4 => "H4",
492 Timeframe::D1 => "D",
493 Timeframe::W1 => "W",
494 Timeframe::MN => "MN",
495 }
496}
497
498pub fn check_book_seq(
500 core: &mut MdCore,
501 sub: SubscriptionId,
502 expected: u64,
503 instrument: brokapi_core::InstrumentId,
504 out: &mut EventSink,
505) {
506 let seq = core.book_seq.get(&sub).copied().unwrap_or(0);
507 if seq > 0 && seq != expected {
508 out.push(CoreEvent {
509 sub: Some(sub),
510 payload: brokapi_core::Event::Resync {
511 sub,
512 instrument,
513 reason: ResyncReason::SequenceGap,
514 },
515 });
516 }
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use brokapi_core::{InstrumentId, InstrumentKey, NativeId};
523
524 fn sber() -> InstrumentId {
525 InstrumentId::new(
526 InstrumentKey::new("SBER", "TQBR"),
527 NativeId::TickerClass {
528 ticker: "SBER".into(),
529 class_code: "TQBR".into(),
530 },
531 )
532 }
533
534 #[test]
535 fn apply_queues_subscribe_outbound() {
536 let mut core = MdCore::new();
537 let sub_id = SubscriptionId::new(1);
538 let desired = SubscriptionSet::with_subscriptions(vec![DesiredSubscription {
539 id: sub_id,
540 kind: SubscriptionKind::OrderBook {
541 instrument: sber(),
542 depth: 20,
543 },
544 }]);
545 core.apply(&desired);
546 let msg = core.poll_transmit().expect("subscribe outbound");
547 match msg {
548 Outbound::WsText(json) => {
549 assert!(json.contains("\"subscribeType\":0"));
550 assert!(json.contains("\"dataType\":0"));
551 }
552 }
553 assert_eq!(core.actual_state(sub_id), Some(SubState::Pending));
554 }
555
556 #[test]
557 fn reconnect_marks_every_order_book_but_the_first_connect_does_not() {
558 let mut core = MdCore::new();
559 let (book, quotes) = (SubscriptionId::new(1), SubscriptionId::new(2));
560 core.apply(&SubscriptionSet::with_subscriptions(vec![
561 DesiredSubscription {
562 id: book,
563 kind: SubscriptionKind::OrderBook {
564 instrument: sber(),
565 depth: 20,
566 },
567 },
568 DesiredSubscription {
569 id: quotes,
570 kind: SubscriptionKind::Quotes { instrument: sber() },
571 },
572 ]));
573 let mut out = EventSink::new();
574 let now = Instant::now();
575
576 core.on_frame(IncomingMessage::Reconnected, now, &mut out);
577 assert!(out.drain().is_empty(), "the first connect is not a loss");
578
579 core.on_frame(IncomingMessage::Reconnected, now, &mut out);
580 let events = out.drain();
581 assert_eq!(
582 events.len(),
583 1,
584 "one Resync per order book subscription, none for quotes"
585 );
586 assert_eq!(events[0].sub, Some(book));
587 assert!(matches!(
588 events[0].payload,
589 Event::Resync { sub, reason: ResyncReason::SubscriptionReset, .. } if sub == book
590 ));
591 assert_eq!(core.actual_state(book), Some(SubState::Pending));
593 assert_eq!(core.actual_state(quotes), Some(SubState::Pending));
594 }
595}