Skip to main content

rustrade_data/exchange/binance/book/
l2.rs

1use super::{super::channel::BinanceChannel, BinanceLevel};
2use crate::{
3    Identifier,
4    books::{OrderBook, OrderBookTimes},
5    event::MarketEvent,
6    exchange::subscription::ExchangeSub,
7    subscription::book::OrderBookEvent,
8};
9use chrono::{DateTime, Utc};
10use derive_more::Constructor;
11use rustrade_instrument::exchange::ExchangeId;
12use rustrade_integration::subscription::SubscriptionId;
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Constructor)]
16pub struct BinanceOrderBookL2Meta<InstrumentKey, Sequencer> {
17    pub key: InstrumentKey,
18    pub sequencer: Sequencer,
19}
20
21/// [`Binance`](super::super::Binance) OrderBook Level2 snapshot HTTP message.
22///
23/// Used as the starting [`OrderBook`] before OrderBook Level2 delta WebSocket updates are
24/// applied.
25///
26/// ### Payload Examples
27/// See docs: <https://binance-docs.github.io/apidocs/spot/en/#order-book>
28/// #### BinanceSpot OrderBookL2Snapshot
29/// ```json
30/// {
31///     "lastUpdateId": 1027024,
32///     "bids": [
33///         ["4.00000000", "431.00000000"]
34///     ],
35///     "asks": [
36///         ["4.00000200", "12.00000000"]
37///     ]
38/// }
39/// ```
40///
41/// #### BinanceFuturesUsd OrderBookL2Snapshot
42/// See docs: <https://binance-docs.github.io/apidocs/futures/en/#order-book>
43/// ```json
44/// {
45///     "lastUpdateId": 1027024,
46///     "E": 1589436922972,
47///     "T": 1589436922959,
48///     "bids": [
49///         ["4.00000000", "431.00000000"]
50///     ],
51///     "asks": [
52///         ["4.00000200", "12.00000000"]
53///     ]
54/// }
55/// ```
56#[derive(Clone, PartialEq, PartialOrd, Debug, Deserialize, Serialize)]
57pub struct BinanceOrderBookL2Snapshot {
58    #[serde(rename = "lastUpdateId")]
59    pub last_update_id: u64,
60    #[serde(default, rename = "E", with = "chrono::serde::ts_milliseconds_option")]
61    pub time_exchange: Option<DateTime<Utc>>,
62    #[serde(default, rename = "T", with = "chrono::serde::ts_milliseconds_option")]
63    pub time_engine: Option<DateTime<Utc>>,
64    pub bids: Vec<BinanceLevel>,
65    pub asks: Vec<BinanceLevel>,
66}
67
68impl<InstrumentKey> From<(ExchangeId, InstrumentKey, BinanceOrderBookL2Snapshot)>
69    for MarketEvent<InstrumentKey, OrderBookEvent>
70{
71    fn from(
72        (exchange, instrument, snapshot): (ExchangeId, InstrumentKey, BinanceOrderBookL2Snapshot),
73    ) -> Self {
74        // `time_received` must be shared between the MarketEvent envelope and the
75        // OrderBook's `OrderBookTimes`, so the book is built here (the only scope
76        // where the binding exists) rather than via a `From<Snapshot>` that has no
77        // timestamp argument. Spot `/api/v3/depth` carries neither "E" nor "T"
78        // (both `None`) → `time_received` is the only liveness signal until the
79        // first diff sets `time_exchange`.
80        //
81        // Deliberate envelope/book asymmetry: the `MarketEvent.time_exchange`
82        // below falls back to `time_received` (its non-`Option` contract always
83        // yields a value), whereas the book's `OrderBookTimes.time_exchange` keeps
84        // the raw `snapshot.time_exchange` (`None` = "the venue gave nothing").
85        let time_received = Utc::now();
86        Self {
87            time_exchange: snapshot.time_exchange.unwrap_or(time_received),
88            time_received,
89            exchange,
90            instrument,
91            kind: OrderBookEvent::Snapshot(OrderBook::new(
92                snapshot.last_update_id,
93                OrderBookTimes {
94                    time_engine: snapshot.time_engine,
95                    time_exchange: snapshot.time_exchange,
96                    time_received,
97                },
98                snapshot.bids,
99                snapshot.asks,
100            )),
101        }
102    }
103}
104
105/// Deserialize a
106/// [`BinanceSpotOrderBookL2Update`](super::super::spot::l2::BinanceSpotOrderBookL2Update) or
107/// [`BinanceFuturesOrderBookL2Update`](super::super::futures::l2::BinanceFuturesOrderBookL2Update)
108/// "s" field (eg/ "BTCUSDT") as the associated [`SubscriptionId`]
109///
110/// eg/ "@depth@100ms|BTCUSDT"
111pub fn de_ob_l2_subscription_id<'de, D>(deserializer: D) -> Result<SubscriptionId, D::Error>
112where
113    D: serde::de::Deserializer<'de>,
114{
115    <&str as Deserialize>::deserialize(deserializer)
116        .map(|market| ExchangeSub::from((BinanceChannel::ORDER_BOOK_L2, market)).id())
117}
118
119#[cfg(test)]
120#[allow(clippy::unwrap_used)] // Test code: panics on bad input are acceptable
121mod tests {
122    use super::*;
123
124    mod de {
125        use super::*;
126        use rust_decimal_macros::dec;
127
128        #[test]
129        fn test_binance_order_book_l2_snapshot() {
130            struct TestCase {
131                input: &'static str,
132                expected: BinanceOrderBookL2Snapshot,
133            }
134
135            let tests = vec![
136                TestCase {
137                    // TC0: valid Spot BinanceOrderBookL2Snapshot
138                    input: r#"
139                    {
140                        "lastUpdateId": 1027024,
141                        "bids": [
142                            [
143                                "4.00000000",
144                                "431.00000000"
145                            ]
146                        ],
147                        "asks": [
148                            [
149                                "4.00000200",
150                                "12.00000000"
151                            ]
152                        ]
153                    }
154                    "#,
155                    expected: BinanceOrderBookL2Snapshot {
156                        last_update_id: 1027024,
157                        time_exchange: Default::default(),
158                        time_engine: Default::default(),
159                        bids: vec![BinanceLevel {
160                            price: dec!(4.00000000),
161                            amount: dec!(431.00000000),
162                        }],
163                        asks: vec![BinanceLevel {
164                            price: dec!(4.00000200),
165                            amount: dec!(12.00000000),
166                        }],
167                    },
168                },
169                TestCase {
170                    // TC1: valid FuturePerpetual BinanceOrderBookL2Snapshot
171                    input: r#"
172                    {
173                        "lastUpdateId": 1027024,
174                        "E": 1589436922972,
175                        "T": 1589436922959,
176                        "bids": [
177                            [
178                                "4.00000000",
179                                "431.00000000"
180                            ]
181                        ],
182                        "asks": [
183                            [
184                                "4.00000200",
185                                "12.00000000"
186                            ]
187                        ]
188                    }
189                    "#,
190                    expected: BinanceOrderBookL2Snapshot {
191                        last_update_id: 1027024,
192                        time_exchange: Some(
193                            DateTime::from_timestamp_millis(1589436922972).unwrap(),
194                        ),
195                        time_engine: Some(DateTime::from_timestamp_millis(1589436922959).unwrap()),
196                        bids: vec![BinanceLevel {
197                            price: dec!(4.0),
198                            amount: dec!(431.0),
199                        }],
200                        asks: vec![BinanceLevel {
201                            price: dec!(4.00000200),
202                            amount: dec!(12.0),
203                        }],
204                    },
205                },
206            ];
207
208            for (index, test) in tests.into_iter().enumerate() {
209                assert_eq!(
210                    serde_json::from_str::<BinanceOrderBookL2Snapshot>(test.input).unwrap(),
211                    test.expected,
212                    "TC{} failed",
213                    index
214                );
215            }
216        }
217    }
218
219    mod seed {
220        use super::*;
221        use rust_decimal_macros::dec;
222
223        fn snapshot(
224            time_exchange: Option<DateTime<Utc>>,
225            time_engine: Option<DateTime<Utc>>,
226        ) -> BinanceOrderBookL2Snapshot {
227            BinanceOrderBookL2Snapshot {
228                last_update_id: 1,
229                time_exchange,
230                time_engine,
231                bids: vec![BinanceLevel {
232                    price: dec!(100),
233                    amount: dec!(1),
234                }],
235                asks: vec![],
236            }
237        }
238
239        #[test]
240        fn futures_seed_carries_distinct_times_not_transposed() {
241            // Futures REST seed carries both "E" and "T": the second site (with the
242            // futures diff) where both are `Some` and differ — guards field ordering.
243            let e = DateTime::from_timestamp_millis(1589436922972).unwrap(); // "E"
244            let t = DateTime::from_timestamp_millis(1589436922959).unwrap(); // "T"
245            assert_ne!(e, t);
246
247            let event: MarketEvent<&str, OrderBookEvent> = (
248                ExchangeId::BinanceFuturesUsd,
249                "inst",
250                snapshot(Some(e), Some(t)),
251            )
252                .into();
253            let OrderBookEvent::Snapshot(book) = event.kind else {
254                panic!("expected Snapshot");
255            };
256            assert_eq!(book.time_exchange(), Some(e), "time_exchange must be \"E\"");
257            assert_eq!(book.time_engine(), Some(t), "time_engine must be \"T\"");
258            // Book's local ingestion time matches the envelope's.
259            assert_eq!(book.time_received(), event.time_received);
260        }
261
262        #[test]
263        fn spot_seed_has_no_venue_times_only_time_received() {
264            // Spot `/api/v3/depth` carries neither "E" nor "T" → both `None`;
265            // `time_received` is the only liveness signal until the first diff.
266            let event: MarketEvent<&str, OrderBookEvent> =
267                (ExchangeId::BinanceSpot, "inst", snapshot(None, None)).into();
268            let OrderBookEvent::Snapshot(book) = event.kind else {
269                panic!("expected Snapshot");
270            };
271            assert_eq!(book.time_exchange(), None);
272            assert_eq!(book.time_engine(), None);
273            assert_eq!(book.time_received(), event.time_received);
274        }
275    }
276}