Skip to main content

brokapi_bybit/core/
md.rs

1//! The market data core: frames of the public stream to events.
2//!
3//! The order book stream sends one snapshot, then deltas. The core keeps a book per subscription and sends the whole
4//! book (cut to the depth asked) after every change, so a reader that falls behind loses nothing by skipping to the
5//! latest. The deltas of one topic are numbered in sequence (`u`): a number that is not the next one means a delta was
6//! lost. The core then drops the book, tells the reader with [`Event::Resync`] and notes the topic in [`MdCore::take_gaps`]:
7//! the connector asks the exchange for a new snapshot.
8
9use 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/// The order book of one subscription, kept from the snapshot and the deltas.
32#[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    /// Applies a delta. `false` when it is not the next one: the book cannot be trusted any more.
62    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/// The sans-io core of the market data connector.
130#[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    /// An empty core.
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Woken whenever a subscription changes state.
147    pub fn notifier(&self) -> std::sync::Arc<Notify> {
148        self.reg.notifier()
149    }
150
151    /// The subscriptions the core serves.
152    pub fn desired(&self) -> &SubscriptionSet {
153        &self.desired
154    }
155
156    /// Registers a subscription: frames of `topic` belong to `id` and are read as `kind`. `req_id` ties the answer of
157    /// the exchange to the topic.
158    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    /// Remembers a request id for a topic (after a book was asked for again).
176    pub fn add_request(&mut self, req_id: &str, topic: &str) {
177        self.reg.add_request(req_id, topic);
178    }
179
180    /// Forgets a subscription. Returns the topics nobody listens to any more.
181    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    /// Whether any subscription listens to the topic.
190    pub fn has_topic(&self, topic: &str) -> bool {
191        self.reg.has_topic(topic)
192    }
193
194    /// Whether the subscription is an order book that waits for a snapshot.
195    pub fn book_waits(&self, id: SubscriptionId) -> bool {
196        self.books.get(&id).is_some_and(|b| !b.synced)
197    }
198
199    /// The state of a subscription.
200    pub fn sub_state(&self, id: SubscriptionId) -> (SubState, Option<String>) {
201        self.reg.state(id).unwrap_or((SubState::Pending, None))
202    }
203
204    /// The topics whose order book lost a delta since the last call. The connector asks the exchange for a new
205    /// snapshot of each.
206    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        // A new connection: the books are void until the new snapshot, and every topic is asked for again.
229        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                    // Waiting for a snapshot: a delta of the old book is of no use.
332                    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            // The same trade twice is told once (a snapshot after a reconnect can repeat the last ones).
378            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                    // `end` is the last millisecond of the candle: it closes one millisecond later.
435                    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        // The requests go out through the WebSocket session of the connector.
486        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        // 99.0 is gone, 100.5 is new, 101.0 gets bigger.
561        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        // Deltas are ignored until the next snapshot, then the book is back.
618        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        // An unreadable frame gives nothing.
797        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}