Skip to main content

nautilus_binance/spot/websocket/streams/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsing utilities for Binance Spot WebSocket SBE messages.
17
18use nautilus_core::nanos::UnixNanos;
19use nautilus_model::{
20    data::{BookOrder, Data, OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick},
21    enums::{AggressorSide, BookAction, OrderSide, RecordFlag},
22    identifiers::TradeId,
23    instruments::{Instrument, InstrumentAny},
24    types::{Price, Quantity},
25};
26
27use crate::spot::sbe::stream::{
28    BestBidAskStreamEvent, DepthDiffStreamEvent, DepthSnapshotStreamEvent, MessageHeader,
29    StreamDecodeError, TradesStreamEvent, template_id,
30};
31
32/// Decoded market data message.
33#[derive(Debug)]
34pub enum MarketDataMessage {
35    /// Trade event.
36    Trades(TradesStreamEvent),
37    /// Best bid/ask update.
38    BestBidAsk(BestBidAskStreamEvent),
39    /// Order book snapshot.
40    DepthSnapshot(DepthSnapshotStreamEvent),
41    /// Order book diff update.
42    DepthDiff(DepthDiffStreamEvent),
43}
44
45/// Decode an SBE binary frame into a market data message.
46///
47/// Validates the message header (including schema ID) and routes to the
48/// appropriate decoder based on template ID.
49///
50/// # Errors
51///
52/// Returns an error if the buffer is too short, schema validation fails,
53/// or the template ID is unknown.
54pub fn decode_market_data(buf: &[u8]) -> Result<MarketDataMessage, StreamDecodeError> {
55    let header = MessageHeader::decode(buf)?;
56    header.validate_schema()?;
57
58    match header.template_id {
59        template_id::TRADES_STREAM_EVENT => Ok(MarketDataMessage::Trades(
60            TradesStreamEvent::decode_validated(buf)?,
61        )),
62        template_id::BEST_BID_ASK_STREAM_EVENT => Ok(MarketDataMessage::BestBidAsk(
63            BestBidAskStreamEvent::decode_validated(buf)?,
64        )),
65        template_id::DEPTH_SNAPSHOT_STREAM_EVENT => Ok(MarketDataMessage::DepthSnapshot(
66            DepthSnapshotStreamEvent::decode_validated(buf)?,
67        )),
68        template_id::DEPTH_DIFF_STREAM_EVENT => Ok(MarketDataMessage::DepthDiff(
69            DepthDiffStreamEvent::decode_validated(buf)?,
70        )),
71        _ => Err(StreamDecodeError::UnknownTemplateId(header.template_id)),
72    }
73}
74
75/// Parses a trades stream event into a vector of `TradeTick`.
76pub fn parse_trades_event(event: &TradesStreamEvent, instrument: &InstrumentAny) -> Vec<Data> {
77    let instrument_id = instrument.id();
78    let price_precision = instrument.price_precision();
79    let size_precision = instrument.size_precision();
80
81    event
82        .trades
83        .iter()
84        .map(|t| {
85            let price = Price::from_mantissa_exponent(
86                t.price_mantissa,
87                event.price_exponent,
88                price_precision,
89            );
90            let size = Quantity::from_mantissa_exponent(
91                t.qty_mantissa as u64,
92                event.qty_exponent,
93                size_precision,
94            );
95            let ts_event = UnixNanos::from_micros(event.transact_time_us as u64);
96
97            let trade = TradeTick::new(
98                instrument_id,
99                price,
100                size,
101                if t.is_buyer_maker {
102                    AggressorSide::Seller
103                } else {
104                    AggressorSide::Buyer
105                },
106                TradeId::new(t.id.to_string()),
107                ts_event,
108                ts_event,
109            );
110            Data::from(trade)
111        })
112        .collect()
113}
114
115/// Parses a best bid/ask event into a `QuoteTick`.
116pub fn parse_bbo_event(event: &BestBidAskStreamEvent, instrument: &InstrumentAny) -> QuoteTick {
117    let instrument_id = instrument.id();
118    let price_precision = instrument.price_precision();
119    let size_precision = instrument.size_precision();
120
121    let bid_price = Price::from_mantissa_exponent(
122        event.bid_price_mantissa,
123        event.price_exponent,
124        price_precision,
125    );
126    let bid_size = Quantity::from_mantissa_exponent(
127        event.bid_qty_mantissa as u64,
128        event.qty_exponent,
129        size_precision,
130    );
131    let ask_price = Price::from_mantissa_exponent(
132        event.ask_price_mantissa,
133        event.price_exponent,
134        price_precision,
135    );
136    let ask_size = Quantity::from_mantissa_exponent(
137        event.ask_qty_mantissa as u64,
138        event.qty_exponent,
139        size_precision,
140    );
141    let ts_event = UnixNanos::from_micros(event.event_time_us as u64);
142
143    QuoteTick::new(
144        instrument_id,
145        bid_price,
146        ask_price,
147        bid_size,
148        ask_size,
149        ts_event,
150        ts_event,
151    )
152}
153
154/// Parses a depth snapshot event into `OrderBookDeltas`.
155///
156/// Returns `None` if the snapshot contains no levels.
157pub fn parse_depth_snapshot(
158    event: &DepthSnapshotStreamEvent,
159    instrument: &InstrumentAny,
160) -> Option<OrderBookDeltas> {
161    let instrument_id = instrument.id();
162    let price_precision = instrument.price_precision();
163    let size_precision = instrument.size_precision();
164    let ts_event = UnixNanos::from_micros(event.event_time_us as u64);
165    let sequence = event.book_update_id as u64;
166
167    let mut deltas = Vec::with_capacity(event.bids.len() + event.asks.len() + 1);
168
169    // Add clear delta first
170    deltas.push(OrderBookDelta::clear(
171        instrument_id,
172        sequence,
173        ts_event,
174        ts_event,
175    ));
176
177    // Add bid levels
178    for (i, level) in event.bids.iter().enumerate() {
179        let price = Price::from_mantissa_exponent(
180            level.price_mantissa,
181            event.price_exponent,
182            price_precision,
183        );
184        let size = Quantity::from_mantissa_exponent(
185            level.qty_mantissa as u64,
186            event.qty_exponent,
187            size_precision,
188        );
189        let flags = if i == event.bids.len() - 1 && event.asks.is_empty() {
190            RecordFlag::F_LAST as u8
191        } else {
192            0
193        };
194
195        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
196
197        deltas.push(OrderBookDelta::new(
198            instrument_id,
199            BookAction::Add,
200            order,
201            flags,
202            sequence,
203            ts_event,
204            ts_event,
205        ));
206    }
207
208    // Add ask levels
209    for (i, level) in event.asks.iter().enumerate() {
210        let price = Price::from_mantissa_exponent(
211            level.price_mantissa,
212            event.price_exponent,
213            price_precision,
214        );
215        let size = Quantity::from_mantissa_exponent(
216            level.qty_mantissa as u64,
217            event.qty_exponent,
218            size_precision,
219        );
220        let flags = if i == event.asks.len() - 1 {
221            RecordFlag::F_LAST as u8
222        } else {
223            0
224        };
225
226        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
227
228        deltas.push(OrderBookDelta::new(
229            instrument_id,
230            BookAction::Add,
231            order,
232            flags,
233            sequence,
234            ts_event,
235            ts_event,
236        ));
237    }
238
239    // A snapshot that only contains the synthetic clear delta has no book levels
240    // to apply and is treated as "no usable update".
241    if deltas.len() <= 1 {
242        return None;
243    }
244
245    Some(OrderBookDeltas::new(instrument_id, deltas))
246}
247
248/// Parses a depth diff event into `OrderBookDeltas`.
249///
250/// Returns `None` if the diff contains no updates.
251pub fn parse_depth_diff(
252    event: &DepthDiffStreamEvent,
253    instrument: &InstrumentAny,
254) -> Option<OrderBookDeltas> {
255    let instrument_id = instrument.id();
256    let price_precision = instrument.price_precision();
257    let size_precision = instrument.size_precision();
258    let ts_event = UnixNanos::from_micros(event.event_time_us as u64);
259    let sequence = event.last_book_update_id as u64;
260
261    let mut deltas = Vec::with_capacity(event.bids.len() + event.asks.len());
262
263    // Add bid updates
264    for (i, level) in event.bids.iter().enumerate() {
265        let price = Price::from_mantissa_exponent(
266            level.price_mantissa,
267            event.price_exponent,
268            price_precision,
269        );
270        let size = Quantity::from_mantissa_exponent(
271            level.qty_mantissa as u64,
272            event.qty_exponent,
273            size_precision,
274        );
275
276        // Zero size means delete, otherwise update
277        let action = if level.qty_mantissa == 0 {
278            BookAction::Delete
279        } else {
280            BookAction::Update
281        };
282
283        let flags = if i == event.bids.len() - 1 && event.asks.is_empty() {
284            RecordFlag::F_LAST as u8
285        } else {
286            0
287        };
288
289        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
290
291        deltas.push(OrderBookDelta::new(
292            instrument_id,
293            action,
294            order,
295            flags,
296            sequence,
297            ts_event,
298            ts_event,
299        ));
300    }
301
302    // Add ask updates
303    for (i, level) in event.asks.iter().enumerate() {
304        let price = Price::from_mantissa_exponent(
305            level.price_mantissa,
306            event.price_exponent,
307            price_precision,
308        );
309        let size = Quantity::from_mantissa_exponent(
310            level.qty_mantissa as u64,
311            event.qty_exponent,
312            size_precision,
313        );
314
315        let action = if level.qty_mantissa == 0 {
316            BookAction::Delete
317        } else {
318            BookAction::Update
319        };
320
321        let flags = if i == event.asks.len() - 1 {
322            RecordFlag::F_LAST as u8
323        } else {
324            0
325        };
326
327        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
328
329        deltas.push(OrderBookDelta::new(
330            instrument_id,
331            action,
332            order,
333            flags,
334            sequence,
335            ts_event,
336            ts_event,
337        ));
338    }
339
340    if deltas.is_empty() {
341        return None;
342    }
343
344    Some(OrderBookDeltas::new(instrument_id, deltas))
345}
346
347#[cfg(test)]
348mod tests {
349    use rstest::rstest;
350    use ustr::Ustr;
351
352    use super::*;
353    use crate::{
354        common::parse::parse_spot_instrument_sbe,
355        spot::{
356            http::models::{
357                BinanceLotSizeFilterSbe, BinancePriceFilterSbe, BinanceSymbolFiltersSbe,
358                BinanceSymbolSbe,
359            },
360            sbe::stream::{PriceLevel, STREAM_SCHEMA_ID, Trade},
361        },
362    };
363
364    fn make_bbo_buffer() -> Vec<u8> {
365        let mut buf = vec![0u8; 70];
366
367        // Header
368        buf[0..2].copy_from_slice(&50u16.to_le_bytes()); // block_length
369        buf[2..4].copy_from_slice(&template_id::BEST_BID_ASK_STREAM_EVENT.to_le_bytes());
370        buf[4..6].copy_from_slice(&STREAM_SCHEMA_ID.to_le_bytes());
371        buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // version
372
373        // Body
374        let body = &mut buf[8..];
375        body[0..8].copy_from_slice(&1000000i64.to_le_bytes()); // event_time_us
376        body[8..16].copy_from_slice(&12345i64.to_le_bytes()); // book_update_id
377        body[16] = (-2i8) as u8; // price_exponent
378        body[17] = (-8i8) as u8; // qty_exponent
379        body[18..26].copy_from_slice(&4200000i64.to_le_bytes()); // bid_price
380        body[26..34].copy_from_slice(&100000000i64.to_le_bytes()); // bid_qty
381        body[34..42].copy_from_slice(&4200100i64.to_le_bytes()); // ask_price
382        body[42..50].copy_from_slice(&200000000i64.to_le_bytes()); // ask_qty
383
384        // Symbol: "BTCUSDT" (7 bytes)
385        body[50] = 7;
386        body[51..58].copy_from_slice(b"BTCUSDT");
387
388        buf
389    }
390
391    fn sample_instrument() -> InstrumentAny {
392        let symbol = BinanceSymbolSbe {
393            symbol: "ETHUSDT".to_string(),
394            base_asset: "ETH".to_string(),
395            quote_asset: "USDT".to_string(),
396            base_asset_precision: 8,
397            quote_asset_precision: 8,
398            status: 0,
399            order_types: 0,
400            iceberg_allowed: true,
401            oco_allowed: true,
402            oto_allowed: false,
403            quote_order_qty_market_allowed: true,
404            allow_trailing_stop: true,
405            cancel_replace_allowed: true,
406            amend_allowed: true,
407            is_spot_trading_allowed: true,
408            is_margin_trading_allowed: false,
409            filters: BinanceSymbolFiltersSbe {
410                price_filter: Some(BinancePriceFilterSbe {
411                    price_exponent: -8,
412                    min_price: 1_000_000,
413                    max_price: 100_000_000_000_000,
414                    tick_size: 1_000_000,
415                }),
416                lot_size_filter: Some(BinanceLotSizeFilterSbe {
417                    qty_exponent: -8,
418                    min_qty: 10_000,
419                    max_qty: 900_000_000_000,
420                    step_size: 10_000,
421                }),
422            },
423            permissions: vec![vec!["SPOT".to_string()]],
424        };
425
426        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
427        parse_spot_instrument_sbe(&symbol, ts, ts).unwrap()
428    }
429
430    #[rstest]
431    fn test_decode_empty_buffer() {
432        let err = decode_market_data(&[]).unwrap_err();
433        assert!(matches!(err, StreamDecodeError::BufferTooShort { .. }));
434    }
435
436    #[rstest]
437    fn test_decode_short_buffer() {
438        let buf = [0u8; 5];
439        let err = decode_market_data(&buf).unwrap_err();
440        assert!(matches!(err, StreamDecodeError::BufferTooShort { .. }));
441    }
442
443    #[rstest]
444    fn test_decode_wrong_schema() {
445        let mut buf = [0u8; 100];
446        buf[0..2].copy_from_slice(&50u16.to_le_bytes()); // block_length
447        buf[2..4].copy_from_slice(&template_id::BEST_BID_ASK_STREAM_EVENT.to_le_bytes());
448        buf[4..6].copy_from_slice(&99u16.to_le_bytes()); // Wrong schema
449        buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // version
450
451        let err = decode_market_data(&buf).unwrap_err();
452        assert!(matches!(err, StreamDecodeError::SchemaMismatch { .. }));
453    }
454
455    #[rstest]
456    fn test_decode_unknown_template() {
457        let mut buf = [0u8; 100];
458        buf[0..2].copy_from_slice(&50u16.to_le_bytes()); // block_length
459        buf[2..4].copy_from_slice(&9999u16.to_le_bytes()); // Unknown template
460        buf[4..6].copy_from_slice(&STREAM_SCHEMA_ID.to_le_bytes());
461        buf[6..8].copy_from_slice(&0u16.to_le_bytes()); // version
462
463        let err = decode_market_data(&buf).unwrap_err();
464        assert!(matches!(err, StreamDecodeError::UnknownTemplateId(9999)));
465    }
466
467    #[rstest]
468    fn test_decode_valid_best_bid_ask() {
469        let buf = make_bbo_buffer();
470        let msg = decode_market_data(&buf).unwrap();
471
472        match msg {
473            MarketDataMessage::BestBidAsk(event) => {
474                assert_eq!(event.event_time_us, 1_000_000);
475                assert_eq!(event.symbol, Ustr::from("BTCUSDT"));
476            }
477            _ => panic!("Expected BestBidAsk"),
478        }
479    }
480
481    #[rstest]
482    fn test_parse_trades_event() {
483        let instrument = sample_instrument();
484        let event = TradesStreamEvent {
485            event_time_us: 1_700_000_000_000_000,
486            transact_time_us: 1_700_000_000_100_000,
487            price_exponent: -2,
488            qty_exponent: -4,
489            trades: vec![
490                Trade {
491                    id: 1,
492                    price_mantissa: 12_345,
493                    qty_mantissa: 25_000,
494                    is_buyer_maker: false,
495                },
496                Trade {
497                    id: 2,
498                    price_mantissa: 12_340,
499                    qty_mantissa: 10_000,
500                    is_buyer_maker: true,
501                },
502            ],
503            symbol: Ustr::from("ETHUSDT"),
504        };
505
506        let data = parse_trades_event(&event, &instrument);
507
508        assert_eq!(data.len(), 2);
509        match &data[0] {
510            Data::Trade(trade) => {
511                assert_eq!(trade.instrument_id, instrument.id());
512                assert_eq!(trade.price, Price::new(123.45, 2));
513                assert_eq!(trade.size, Quantity::new(2.5, 4));
514                assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
515                assert_eq!(trade.trade_id, TradeId::new("1"));
516                assert_eq!(
517                    trade.ts_event,
518                    UnixNanos::from(1_700_000_000_100_000_000u64)
519                );
520                assert_eq!(trade.ts_init, UnixNanos::from(1_700_000_000_100_000_000u64));
521            }
522            other => panic!("Expected trade data, was {other:?}"),
523        }
524
525        match &data[1] {
526            Data::Trade(trade) => assert_eq!(trade.aggressor_side, AggressorSide::Seller),
527            other => panic!("Expected trade data, was {other:?}"),
528        }
529    }
530
531    #[rstest]
532    fn test_parse_bbo_event() {
533        let instrument = sample_instrument();
534        let event = BestBidAskStreamEvent {
535            event_time_us: 1_700_000_000_000_000,
536            book_update_id: 123,
537            price_exponent: -2,
538            qty_exponent: -4,
539            bid_price_mantissa: 12_345,
540            bid_qty_mantissa: 25_000,
541            ask_price_mantissa: 12_350,
542            ask_qty_mantissa: 30_000,
543            symbol: Ustr::from("ETHUSDT"),
544        };
545
546        let quote = parse_bbo_event(&event, &instrument);
547
548        assert_eq!(quote.instrument_id, instrument.id());
549        assert_eq!(quote.bid_price, Price::new(123.45, 2));
550        assert_eq!(quote.ask_price, Price::new(123.50, 2));
551        assert_eq!(quote.bid_size, Quantity::new(2.5, 4));
552        assert_eq!(quote.ask_size, Quantity::new(3.0, 4));
553        assert_eq!(
554            quote.ts_event,
555            UnixNanos::from(1_700_000_000_000_000_000u64)
556        );
557        assert_eq!(quote.ts_init, UnixNanos::from(1_700_000_000_000_000_000u64));
558    }
559
560    #[rstest]
561    fn test_parse_depth_snapshot() {
562        let instrument = sample_instrument();
563        let event = DepthSnapshotStreamEvent {
564            event_time_us: 1_700_000_000_000_000,
565            book_update_id: 123,
566            price_exponent: -2,
567            qty_exponent: -4,
568            bids: vec![PriceLevel {
569                price_mantissa: 12_345,
570                qty_mantissa: 25_000,
571            }],
572            asks: vec![PriceLevel {
573                price_mantissa: 12_350,
574                qty_mantissa: 30_000,
575            }],
576            symbol: Ustr::from("ETHUSDT"),
577        };
578
579        let deltas = parse_depth_snapshot(&event, &instrument).unwrap();
580
581        assert_eq!(deltas.instrument_id, instrument.id());
582        assert_eq!(deltas.deltas.len(), 3);
583        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
584        assert_eq!(deltas.deltas[1].action, BookAction::Add);
585        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
586        assert_eq!(deltas.deltas[1].order.price, Price::new(123.45, 2));
587        assert_eq!(deltas.deltas[1].order.size, Quantity::new(2.5, 4));
588        assert_eq!(deltas.deltas[2].action, BookAction::Add);
589        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell);
590        assert_eq!(deltas.deltas[2].order.price, Price::new(123.50, 2));
591        assert_eq!(deltas.deltas[2].order.size, Quantity::new(3.0, 4));
592        assert_eq!(deltas.deltas[2].flags, RecordFlag::F_LAST as u8);
593        assert_eq!(deltas.deltas[0].sequence, 123);
594        assert_eq!(deltas.deltas[2].sequence, 123);
595        assert_eq!(
596            deltas.ts_event,
597            UnixNanos::from(1_700_000_000_000_000_000u64)
598        );
599    }
600
601    #[rstest]
602    fn test_parse_depth_snapshot_empty_returns_none() {
603        let instrument = sample_instrument();
604        let event = DepthSnapshotStreamEvent {
605            event_time_us: 1_700_000_000_000_000,
606            book_update_id: 123,
607            price_exponent: -2,
608            qty_exponent: -4,
609            bids: vec![],
610            asks: vec![],
611            symbol: Ustr::from("ETHUSDT"),
612        };
613
614        let deltas = parse_depth_snapshot(&event, &instrument);
615
616        assert!(deltas.is_none());
617    }
618
619    #[rstest]
620    fn test_parse_depth_diff() {
621        let instrument = sample_instrument();
622        let event = DepthDiffStreamEvent {
623            event_time_us: 1_700_000_000_000_000,
624            first_book_update_id: 100,
625            last_book_update_id: 101,
626            price_exponent: -2,
627            qty_exponent: -4,
628            bids: vec![
629                PriceLevel {
630                    price_mantissa: 12_345,
631                    qty_mantissa: 25_000,
632                },
633                PriceLevel {
634                    price_mantissa: 12_340,
635                    qty_mantissa: 0,
636                },
637            ],
638            asks: vec![PriceLevel {
639                price_mantissa: 12_350,
640                qty_mantissa: 30_000,
641            }],
642            symbol: Ustr::from("ETHUSDT"),
643        };
644
645        let deltas = parse_depth_diff(&event, &instrument).unwrap();
646
647        assert_eq!(deltas.instrument_id, instrument.id());
648        assert_eq!(deltas.deltas.len(), 3);
649        assert_eq!(deltas.deltas[0].action, BookAction::Update);
650        assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy);
651        assert_eq!(deltas.deltas[1].action, BookAction::Delete);
652        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
653        assert_eq!(deltas.deltas[2].action, BookAction::Update);
654        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell);
655        assert_eq!(deltas.deltas[2].flags, RecordFlag::F_LAST as u8);
656        assert_eq!(deltas.deltas[0].sequence, 101);
657        assert_eq!(deltas.deltas[2].sequence, 101);
658        assert_eq!(
659            deltas.ts_event,
660            UnixNanos::from(1_700_000_000_000_000_000u64)
661        );
662    }
663
664    #[rstest]
665    fn test_parse_depth_diff_empty_returns_none() {
666        let instrument = sample_instrument();
667        let event = DepthDiffStreamEvent {
668            event_time_us: 1_700_000_000_000_000,
669            first_book_update_id: 100,
670            last_book_update_id: 101,
671            price_exponent: -2,
672            qty_exponent: -4,
673            bids: vec![],
674            asks: vec![],
675            symbol: Ustr::from("ETHUSDT"),
676        };
677
678        let deltas = parse_depth_diff(&event, &instrument);
679
680        assert!(deltas.is_none());
681    }
682}