1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
10use std::time::Instant;
11
12use brokapi_bybit_protocol::http::models::Level;
13use brokapi_bybit_protocol::ws::models::{DataKind, WsIncoming};
14use brokapi_core::{
15 BookLevel, Candle, Decimal, Event, InstrumentId, MarketTrade, OrderBook, Price, Qty,
16 ResyncReason, Side, SubscriptionId, Timeframe,
17};
18use brokapi_sansio::{
19 BrokerCore, CoreEvent, DesiredSubscription, EventSink, Outbound, SubscriptionKind,
20 SubscriptionSet,
21};
22use serde::Deserialize;
23use tokio::sync::Notify;
24use tracing::{debug, warn};
25
26use super::registry::{Registry, SubState};
27use crate::map::{broker_ts, ms_to_nanos, timestamps};
28
29const SEEN_TRADES_CAP: usize = 20_000;
30
31#[derive(Debug, Default)]
33struct LocalBook {
34 bids: BTreeMap<Decimal, Decimal>,
35 asks: BTreeMap<Decimal, Decimal>,
36 last_u: i64,
37 synced: bool,
38}
39
40impl LocalBook {
41 fn replace(&mut self, bids: &[Level], asks: &[Level], u: i64) {
42 self.bids.clear();
43 self.asks.clear();
44 self.apply(bids, asks);
45 self.last_u = u;
46 self.synced = true;
47 }
48
49 fn apply(&mut self, bids: &[Level], asks: &[Level]) {
50 for (side, levels) in [(&mut self.bids, bids), (&mut self.asks, asks)] {
51 for l in levels {
52 if l.1.is_zero() {
53 side.remove(&l.0);
54 } else {
55 side.insert(l.0, l.1);
56 }
57 }
58 }
59 }
60
61 fn delta(&mut self, bids: &[Level], asks: &[Level], u: i64) -> bool {
63 if !self.synced || u != self.last_u + 1 {
64 return false;
65 }
66 self.apply(bids, asks);
67 self.last_u = u;
68 true
69 }
70
71 fn top(&self, depth: usize) -> (Vec<BookLevel>, Vec<BookLevel>) {
72 let lv = |(p, q): (&Decimal, &Decimal)| BookLevel {
73 price: Price::new(*p),
74 qty: Qty::new(*q),
75 };
76 (
77 self.bids.iter().rev().take(depth).map(lv).collect(),
78 self.asks.iter().take(depth).map(lv).collect(),
79 )
80 }
81}
82
83#[derive(Debug, Deserialize)]
84struct WsBook {
85 #[serde(rename = "b", default)]
86 bids: Vec<Level>,
87 #[serde(rename = "a", default)]
88 asks: Vec<Level>,
89 #[serde(rename = "u", default)]
90 u: i64,
91}
92
93#[derive(Debug, Deserialize)]
94struct WsTrade {
95 #[serde(rename = "T", default)]
96 time: i64,
97 #[serde(rename = "S")]
98 side: String,
99 #[serde(
100 rename = "v",
101 deserialize_with = "brokapi_bybit_protocol::http::models::dec"
102 )]
103 qty: Decimal,
104 #[serde(
105 rename = "p",
106 deserialize_with = "brokapi_bybit_protocol::http::models::dec"
107 )]
108 price: Decimal,
109 #[serde(rename = "i", default)]
110 id: String,
111}
112
113#[derive(Debug, Deserialize)]
114struct WsKline {
115 start: i64,
116 end: i64,
117 #[serde(deserialize_with = "brokapi_bybit_protocol::http::models::dec")]
118 open: Decimal,
119 #[serde(deserialize_with = "brokapi_bybit_protocol::http::models::dec")]
120 close: Decimal,
121 #[serde(deserialize_with = "brokapi_bybit_protocol::http::models::dec")]
122 high: Decimal,
123 #[serde(deserialize_with = "brokapi_bybit_protocol::http::models::dec")]
124 low: Decimal,
125 #[serde(deserialize_with = "brokapi_bybit_protocol::http::models::dec")]
126 volume: Decimal,
127}
128
129#[derive(Debug, Default)]
131pub struct MdCore {
132 reg: Registry<SubscriptionKind>,
133 books: HashMap<SubscriptionId, LocalBook>,
134 seen_trades: HashMap<SubscriptionId, (HashSet<String>, VecDeque<String>)>,
135 gaps: Vec<String>,
136 connections: u32,
137 desired: SubscriptionSet,
138}
139
140impl MdCore {
141 pub fn new() -> Self {
143 Self::default()
144 }
145
146 pub fn notifier(&self) -> std::sync::Arc<Notify> {
148 self.reg.notifier()
149 }
150
151 pub fn desired(&self) -> &SubscriptionSet {
153 &self.desired
154 }
155
156 pub fn register(
159 &mut self,
160 id: SubscriptionId,
161 kind: SubscriptionKind,
162 topic: &str,
163 req_id: Option<&str>,
164 ) {
165 if matches!(kind, SubscriptionKind::OrderBook { .. }) {
166 self.books.insert(id, LocalBook::default());
167 }
168 self.reg.register(id, kind, vec![topic.to_string()]);
169 if let Some(r) = req_id {
170 self.reg.add_request(r, topic);
171 }
172 self.refresh_desired();
173 }
174
175 pub fn add_request(&mut self, req_id: &str, topic: &str) {
177 self.reg.add_request(req_id, topic);
178 }
179
180 pub fn forget(&mut self, id: SubscriptionId) -> Vec<String> {
182 self.books.remove(&id);
183 self.seen_trades.remove(&id);
184 let orphans = self.reg.forget(id);
185 self.refresh_desired();
186 orphans
187 }
188
189 pub fn has_topic(&self, topic: &str) -> bool {
191 self.reg.has_topic(topic)
192 }
193
194 pub fn book_waits(&self, id: SubscriptionId) -> bool {
196 self.books.get(&id).is_some_and(|b| !b.synced)
197 }
198
199 pub fn sub_state(&self, id: SubscriptionId) -> (SubState, Option<String>) {
201 self.reg.state(id).unwrap_or((SubState::Pending, None))
202 }
203
204 pub fn take_gaps(&mut self) -> Vec<String> {
207 std::mem::take(&mut self.gaps)
208 }
209
210 fn refresh_desired(&mut self) {
211 let mut subs: Vec<DesiredSubscription> = self
212 .reg
213 .iter()
214 .map(|(id, kind)| DesiredSubscription {
215 id,
216 kind: kind.clone(),
217 })
218 .collect();
219 subs.sort_by_key(|s| s.id.value());
220 self.desired = SubscriptionSet::with_subscriptions(subs);
221 }
222
223 fn on_connected(&mut self, out: &mut EventSink) {
224 self.connections += 1;
225 if self.connections < 2 {
226 return;
227 }
228 self.reg.reset();
230 let books: Vec<(SubscriptionId, InstrumentId)> = self
231 .reg
232 .iter()
233 .filter_map(|(id, k)| match k {
234 SubscriptionKind::OrderBook { instrument, .. } => Some((id, instrument.clone())),
235 _ => None,
236 })
237 .collect();
238 for (id, instrument) in books {
239 if let Some(b) = self.books.get_mut(&id) {
240 b.synced = false;
241 }
242 out.push(CoreEvent {
243 sub: Some(id),
244 payload: Event::Resync {
245 sub: id,
246 instrument,
247 reason: ResyncReason::SubscriptionReset,
248 },
249 });
250 }
251 }
252
253 #[allow(clippy::too_many_arguments)]
254 fn on_data(
255 &mut self,
256 topic: &str,
257 kind: DataKind,
258 ts: i64,
259 cts: i64,
260 data: &serde_json::Value,
261 now: Instant,
262 out: &mut EventSink,
263 ) {
264 self.reg.on_data(topic);
265 let stamp = broker_ts(if cts > 0 { cts } else { ts });
266 for id in self.reg.subs_of(topic) {
267 let Some(sub_kind) = self.reg.kind(id).cloned() else {
268 continue;
269 };
270 let payload = match sub_kind {
271 SubscriptionKind::OrderBook { instrument, depth } => self.book_event(
272 id,
273 &instrument,
274 depth as usize,
275 kind,
276 topic,
277 data,
278 stamp,
279 now,
280 out,
281 ),
282 SubscriptionKind::Quotes { instrument } => read::<WsBook>(data).map(|b| {
283 Event::Quote(crate::map::market::quote(
284 b.bids.first(),
285 b.asks.first(),
286 cts.max(ts),
287 &instrument,
288 now,
289 ))
290 }),
291 SubscriptionKind::LastTrades { instrument } => {
292 self.trade_events(id, &instrument, data, now, out);
293 None
294 }
295 SubscriptionKind::LastCandle {
296 instrument,
297 timeframe,
298 } => {
299 self.candle_events(id, &instrument, timeframe, data, now, out);
300 None
301 }
302 _ => None,
303 };
304 if let Some(payload) = payload {
305 out.push(CoreEvent {
306 sub: Some(id),
307 payload,
308 });
309 }
310 }
311 }
312
313 #[allow(clippy::too_many_arguments)]
314 fn book_event(
315 &mut self,
316 id: SubscriptionId,
317 instrument: &InstrumentId,
318 depth: usize,
319 kind: DataKind,
320 topic: &str,
321 data: &serde_json::Value,
322 stamp: Option<brokapi_core::UnixNanos>,
323 now: Instant,
324 out: &mut EventSink,
325 ) -> Option<Event> {
326 let wire = read::<WsBook>(data)?;
327 let book = self.books.entry(id).or_default();
328 match kind {
329 DataKind::Delta => {
330 if !book.synced {
331 return None;
333 }
334 if !book.delta(&wire.bids, &wire.asks, wire.u) {
335 warn!(
336 "order book {topic}: delta {} after {} is not the next one",
337 wire.u, book.last_u
338 );
339 book.synced = false;
340 if !self.gaps.iter().any(|t| t == topic) {
341 self.gaps.push(topic.to_string());
342 }
343 out.push(CoreEvent {
344 sub: Some(id),
345 payload: Event::Resync {
346 sub: id,
347 instrument: instrument.clone(),
348 reason: ResyncReason::SequenceGap,
349 },
350 });
351 return None;
352 }
353 }
354 DataKind::Snapshot | DataKind::Event => book.replace(&wire.bids, &wire.asks, wire.u),
355 }
356 let (bids, asks) = book.top(depth.max(1));
357 Some(Event::OrderBook(OrderBook::Snapshot {
358 instrument: instrument.clone(),
359 bids,
360 asks,
361 ts: timestamps(now, stamp),
362 }))
363 }
364
365 fn trade_events(
366 &mut self,
367 id: SubscriptionId,
368 instrument: &InstrumentId,
369 data: &serde_json::Value,
370 now: Instant,
371 out: &mut EventSink,
372 ) {
373 let Some(trades) = read::<Vec<WsTrade>>(data) else {
374 return;
375 };
376 for t in trades {
377 if !t.id.is_empty() {
379 let (seen, order) = self.seen_trades.entry(id).or_default();
380 if !seen.insert(t.id.clone()) {
381 continue;
382 }
383 order.push_back(t.id.clone());
384 if order.len() > SEEN_TRADES_CAP
385 && let Some(old) = order.pop_front()
386 {
387 seen.remove(&old);
388 }
389 }
390 let side = match t.side.as_str() {
391 "Buy" => Side::Buy,
392 "Sell" => Side::Sell,
393 _ => continue,
394 };
395 out.push(CoreEvent {
396 sub: Some(id),
397 payload: Event::MarketTrade(MarketTrade {
398 instrument: instrument.clone(),
399 price: Price::new(t.price),
400 qty: Qty::new(t.qty),
401 side,
402 trade_id: (!t.id.is_empty()).then_some(t.id),
403 ts: timestamps(now, broker_ts(t.time)),
404 }),
405 });
406 }
407 }
408
409 fn candle_events(
410 &self,
411 id: SubscriptionId,
412 instrument: &InstrumentId,
413 tf: Timeframe,
414 data: &serde_json::Value,
415 now: Instant,
416 out: &mut EventSink,
417 ) {
418 let Some(klines) = read::<Vec<WsKline>>(data) else {
419 return;
420 };
421 for k in klines {
422 let open_ts = ms_to_nanos(k.start);
423 out.push(CoreEvent {
424 sub: Some(id),
425 payload: Event::Candle(Candle {
426 instrument: instrument.clone(),
427 timeframe: tf,
428 open: Price::new(k.open),
429 high: Price::new(k.high),
430 low: Price::new(k.low),
431 close: Price::new(k.close),
432 volume: Qty::new(k.volume),
433 open_ts,
434 close_ts: ms_to_nanos(k.end + 1),
436 ts: timestamps(now, Some(open_ts)),
437 }),
438 });
439 }
440 }
441}
442
443fn read<T: serde::de::DeserializeOwned>(data: &serde_json::Value) -> Option<T> {
444 match serde_json::from_value(data.clone()) {
445 Ok(v) => Some(v),
446 Err(e) => {
447 warn!("a data frame cannot be read: {e}");
448 None
449 }
450 }
451}
452
453impl BrokerCore for MdCore {
454 type Frame = WsIncoming;
455
456 fn on_frame(&mut self, frame: Self::Frame, now: Instant, out: &mut EventSink) {
457 match frame {
458 WsIncoming::Connected => self.on_connected(out),
459 WsIncoming::Data {
460 topic,
461 kind,
462 ts,
463 cts,
464 data,
465 } => {
466 if !self.reg.has_topic(&topic) {
467 debug!("a frame of an unknown topic {topic} is dropped");
468 return;
469 }
470 self.on_data(&topic, kind, ts, cts, &data, now, out);
471 }
472 WsIncoming::Ack {
473 op,
474 req_id: Some(req_id),
475 success,
476 message,
477 } if op == "subscribe" => {
478 self.reg.on_ack(&req_id, success, &message);
479 }
480 WsIncoming::Ack { .. } | WsIncoming::Pong | WsIncoming::Unknown(_) => {}
481 }
482 }
483
484 fn poll_transmit(&mut self) -> Option<Outbound> {
485 None
487 }
488
489 fn poll_timeout(&self) -> Option<Instant> {
490 None
491 }
492
493 fn on_timeout(&mut self, _now: Instant, _out: &mut EventSink) {}
494
495 fn apply(&mut self, desired: &SubscriptionSet) {
496 self.desired = desired.clone();
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use crate::util::instrument_id;
504 use brokapi_bybit_protocol::ws::models::parse_incoming;
505 use serde_json::json;
506
507 fn btc() -> InstrumentId {
508 instrument_id("BTCUSDT", "linear")
509 }
510
511 fn frame(v: serde_json::Value) -> WsIncoming {
512 parse_incoming(&v.to_string())
513 }
514
515 fn book_core(depth: u32) -> (MdCore, SubscriptionId) {
516 let mut core = MdCore::new();
517 let id = SubscriptionId::new(1);
518 core.register(
519 id,
520 SubscriptionKind::OrderBook {
521 instrument: btc(),
522 depth,
523 },
524 "orderbook.50.BTCUSDT",
525 Some("r1"),
526 );
527 (core, id)
528 }
529
530 fn snapshot() -> WsIncoming {
531 frame(
532 json!({"topic":"orderbook.50.BTCUSDT","type":"snapshot","ts":5,"cts":4,"data":{
533 "s":"BTCUSDT","b":[["100.0","1"],["99.0","2"],["98.0","3"]],"a":[["101.0","1"],["102.0","2"]],"u":10,"seq":1}}),
534 )
535 }
536
537 fn delta(u: i64, bids: serde_json::Value, asks: serde_json::Value) -> WsIncoming {
538 frame(
539 json!({"topic":"orderbook.50.BTCUSDT","type":"delta","ts":6,"data":{"s":"BTCUSDT","b":bids,"a":asks,"u":u,"seq":2}}),
540 )
541 }
542
543 #[test]
544 fn a_snapshot_then_deltas_make_the_book_and_the_depth_cuts_it() {
545 let (mut core, id) = book_core(2);
546 let mut out = EventSink::new();
547 let now = Instant::now();
548 core.on_frame(
549 WsIncoming::Ack {
550 op: "subscribe".into(),
551 req_id: Some("r1".into()),
552 success: true,
553 message: String::new(),
554 },
555 now,
556 &mut out,
557 );
558 assert_eq!(core.sub_state(id).0, SubState::Active);
559 core.on_frame(snapshot(), now, &mut out);
560 core.on_frame(
562 delta(
563 11,
564 json!([["99.0", "0"], ["100.5", "4"]]),
565 json!([["101.0", "7"]]),
566 ),
567 now,
568 &mut out,
569 );
570 let events = out.drain();
571 assert_eq!(events.len(), 2);
572 let Event::OrderBook(OrderBook::Snapshot { bids, asks, ts, .. }) = &events[1].payload
573 else {
574 panic!("a book");
575 };
576 assert_eq!(events[1].sub, Some(id));
577 assert_eq!(
578 bids.iter().map(|l| l.price.to_string()).collect::<Vec<_>>(),
579 ["100.0", "100.5"]
580 .iter()
581 .rev()
582 .map(|s| s.to_string())
583 .collect::<Vec<_>>()
584 );
585 assert_eq!(asks[0].qty.to_string(), "7");
586 assert_eq!(asks.len(), 2, "cut to the depth asked");
587 assert_eq!(
588 ts.broker_ts.unwrap().as_nanos(),
589 6_000_000,
590 "a delta has no engine time: the time of the frame is used"
591 );
592 }
593
594 #[test]
595 fn a_delta_that_is_not_the_next_one_drops_the_book_and_asks_for_a_new_snapshot() {
596 let (mut core, id) = book_core(50);
597 let mut out = EventSink::new();
598 let now = Instant::now();
599 core.on_frame(snapshot(), now, &mut out);
600 core.on_frame(delta(12, json!([["100.0", "9"]]), json!([])), now, &mut out);
601 let events = out.drain();
602 assert_eq!(
603 events.len(),
604 2,
605 "the snapshot and the Resync, no book from the bad delta"
606 );
607 assert!(matches!(
608 events[1].payload,
609 Event::Resync {
610 reason: ResyncReason::SequenceGap,
611 ..
612 }
613 ));
614 assert_eq!(core.take_gaps(), vec!["orderbook.50.BTCUSDT".to_string()]);
615 assert!(core.take_gaps().is_empty());
616 assert!(core.book_waits(id));
617 core.on_frame(delta(13, json!([["100.0", "9"]]), json!([])), now, &mut out);
619 assert!(out.drain().is_empty());
620 core.on_frame(snapshot(), now, &mut out);
621 assert_eq!(out.drain().len(), 1);
622 assert!(!core.book_waits(id));
623 }
624
625 #[test]
626 fn a_reconnect_voids_the_books_but_the_first_connect_does_not() {
627 let (mut core, book) = book_core(50);
628 let quotes = SubscriptionId::new(2);
629 core.register(
630 quotes,
631 SubscriptionKind::Quotes { instrument: btc() },
632 "orderbook.1.BTCUSDT",
633 None,
634 );
635 let mut out = EventSink::new();
636 let now = Instant::now();
637 core.on_frame(WsIncoming::Connected, now, &mut out);
638 assert!(out.drain().is_empty(), "the first connect is not a loss");
639 core.on_frame(snapshot(), now, &mut out);
640 out.drain();
641 core.on_frame(WsIncoming::Connected, now, &mut out);
642 let events = out.drain();
643 assert_eq!(
644 events.len(),
645 1,
646 "one Resync per order book, none for quotes"
647 );
648 assert!(matches!(
649 events[0].payload,
650 Event::Resync { sub, reason: ResyncReason::SubscriptionReset, .. } if sub == book
651 ));
652 assert_eq!(core.sub_state(quotes).0, SubState::Pending);
653 assert!(core.book_waits(book));
654 }
655
656 #[test]
657 fn a_quote_comes_from_the_best_levels_and_a_frame_of_an_unknown_topic_is_dropped() {
658 let mut core = MdCore::new();
659 let id = SubscriptionId::new(1);
660 core.register(
661 id,
662 SubscriptionKind::Quotes { instrument: btc() },
663 "orderbook.1.BTCUSDT",
664 None,
665 );
666 let mut out = EventSink::new();
667 let now = Instant::now();
668 core.on_frame(
669 frame(json!({"topic":"orderbook.1.BTCUSDT","type":"snapshot","ts":1,"data":{"s":"BTCUSDT","b":[["81695.8","3.824"]],"a":[["81695.9","0.133"]],"u":1,"seq":1},"cts":2})),
670 now,
671 &mut out,
672 );
673 core.on_frame(frame(json!({"topic":"orderbook.1.ETHUSDT","type":"snapshot","ts":1,"data":{"b":[],"a":[]}})), now, &mut out);
674 let events = out.drain();
675 assert_eq!(events.len(), 1);
676 let Event::Quote(q) = &events[0].payload else {
677 panic!("a quote")
678 };
679 assert_eq!(q.bid.as_ref().unwrap().price.to_string(), "81695.8");
680 assert_eq!(q.ask.as_ref().unwrap().qty.to_string(), "0.133");
681 }
682
683 #[test]
684 fn two_listeners_of_one_topic_both_get_the_frames() {
685 let mut core = MdCore::new();
686 let (a, b) = (SubscriptionId::new(1), SubscriptionId::new(2));
687 core.register(
688 a,
689 SubscriptionKind::Quotes { instrument: btc() },
690 "orderbook.1.BTCUSDT",
691 None,
692 );
693 core.register(
694 b,
695 SubscriptionKind::OrderBook {
696 instrument: btc(),
697 depth: 1,
698 },
699 "orderbook.1.BTCUSDT",
700 None,
701 );
702 let mut out = EventSink::new();
703 core.on_frame(
704 frame(json!({"topic":"orderbook.1.BTCUSDT","type":"snapshot","ts":1,"data":{"b":[["1","1"]],"a":[["2","1"]],"u":1}})),
705 Instant::now(),
706 &mut out,
707 );
708 let events = out.drain();
709 assert_eq!(
710 events.iter().map(|e| e.sub).collect::<Vec<_>>(),
711 vec![Some(a), Some(b)]
712 );
713 assert_eq!(core.forget(a), Vec::<String>::new());
714 assert_eq!(core.forget(b), vec!["orderbook.1.BTCUSDT".to_string()]);
715 }
716
717 #[test]
718 fn trades_are_told_once_and_a_candle_closes_a_millisecond_after_its_end() {
719 let mut core = MdCore::new();
720 let (t, c) = (SubscriptionId::new(1), SubscriptionId::new(2));
721 core.register(
722 t,
723 SubscriptionKind::LastTrades { instrument: btc() },
724 "publicTrade.BTCUSDT",
725 None,
726 );
727 core.register(
728 c,
729 SubscriptionKind::LastCandle {
730 instrument: btc(),
731 timeframe: Timeframe::M1,
732 },
733 "kline.1.BTCUSDT",
734 None,
735 );
736 let mut out = EventSink::new();
737 let now = Instant::now();
738 let trade = json!({"topic":"publicTrade.BTCUSDT","type":"snapshot","ts":9,"data":[
739 {"T":9,"s":"BTCUSDT","S":"Buy","v":"0.074","p":"81695.90","L":"PlusTick","i":"x1","BT":false,"seq":1},
740 {"T":9,"s":"BTCUSDT","S":"Sell","v":"0.001","p":"81695.80","L":"MinusTick","i":"x2","BT":false,"seq":2}]});
741 core.on_frame(frame(trade.clone()), now, &mut out);
742 core.on_frame(frame(trade), now, &mut out);
743 let events = out.drain();
744 assert_eq!(events.len(), 2, "the repeat adds nothing");
745 let Event::MarketTrade(m) = &events[0].payload else {
746 panic!("a trade")
747 };
748 assert_eq!(
749 (m.side, m.price.to_string(), m.qty.to_string()),
750 (Side::Buy, "81695.90".into(), "0.074".into())
751 );
752 core.on_frame(
753 frame(json!({"topic":"kline.1.BTCUSDT","type":"snapshot","ts":1,"data":[{"start":1792760100000_i64,"end":1792760159999_i64,
754 "interval":"1","open":"81682.4","close":"81695.9","high":"81704.9","low":"81672","volume":"8.011","turnover":"1","confirm":false,"timestamp":1}]})),
755 now,
756 &mut out,
757 );
758 let events = out.drain();
759 let Event::Candle(k) = &events[0].payload else {
760 panic!("a candle")
761 };
762 assert_eq!(k.close_ts.as_nanos() - k.open_ts.as_nanos(), 60_000_000_000);
763 assert_eq!(k.close.to_string(), "81695.9");
764 }
765
766 #[test]
767 fn a_refused_subscription_keeps_the_reason_and_the_core_has_no_timers() {
768 let mut core = MdCore::new();
769 let id = SubscriptionId::new(1);
770 core.register(
771 id,
772 SubscriptionKind::Quotes { instrument: btc() },
773 "orderbook.1.NOPE",
774 Some("r9"),
775 );
776 assert_eq!(core.desired().subs().len(), 1);
777 let mut out = EventSink::new();
778 core.on_frame(
779 WsIncoming::Ack {
780 op: "subscribe".into(),
781 req_id: Some("r9".into()),
782 success: false,
783 message: "handler not found".into(),
784 },
785 Instant::now(),
786 &mut out,
787 );
788 assert_eq!(
789 core.sub_state(id),
790 (SubState::Failed, Some("handler not found".into()))
791 );
792 assert!(core.poll_transmit().is_none() && core.poll_timeout().is_none());
793 core.on_timeout(Instant::now(), &mut out);
794 core.apply(&SubscriptionSet::new());
795 assert!(core.desired().is_empty());
796 core.register(
798 SubscriptionId::new(2),
799 SubscriptionKind::LastTrades { instrument: btc() },
800 "publicTrade.X",
801 None,
802 );
803 core.on_frame(
804 frame(json!({"topic":"publicTrade.X","type":"snapshot","ts":1,"data":"nonsense"})),
805 Instant::now(),
806 &mut out,
807 );
808 assert!(out.drain().is_empty());
809 }
810}