Skip to main content

nautilus_binance/spot/websocket/public_json/
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 public JSON WebSocket messages.
17
18use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::nanos::UnixNanos;
22use nautilus_model::{
23    data::{
24        BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick, TradeTick,
25    },
26    enums::{
27        AggregationSource, AggressorSide, BarAggregation, BookAction, OrderSide, PriceType,
28        RecordFlag,
29    },
30    identifiers::TradeId,
31    instruments::{Instrument, InstrumentAny},
32    types::{Price, Quantity},
33};
34use rust_decimal::Decimal;
35
36use super::messages::{
37    BinanceSpotBookTickerMsg, BinanceSpotDepthDiffMsg, BinanceSpotKlineMsg,
38    BinanceSpotPartialDepthMsg, BinanceSpotTickerMsg, BinanceSpotTradeMsg,
39};
40use crate::{
41    common::{
42        bar::BinanceBar,
43        enums::BinanceKlineInterval,
44        parse::{parse_millis_or_init, parse_price_at_precision, parse_quantity_at_precision},
45    },
46    data_types::BinanceSpotTicker,
47};
48
49fn parse_positive_price(raw: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
50    parse_price_at_precision(raw, precision)
51        .ok_or_else(|| anyhow::anyhow!("invalid {field} `{raw}`"))
52}
53
54fn parse_positive_quantity(raw: &str, precision: u8, field: &str) -> anyhow::Result<Quantity> {
55    parse_quantity_at_precision(raw, precision)
56        .ok_or_else(|| anyhow::anyhow!("invalid {field} `{raw}`"))
57}
58
59fn parse_non_negative_quantity(raw: &str, precision: u8, field: &str) -> anyhow::Result<Quantity> {
60    let decimal = Decimal::from_str(raw).with_context(|| format!("invalid {field} `{raw}`"))?;
61    if decimal.is_sign_negative() {
62        anyhow::bail!("invalid {field} `{raw}`");
63    }
64
65    Quantity::from_decimal_dp(decimal, precision)
66        .map_err(|e| anyhow::anyhow!("invalid {field} `{raw}`: {e}"))
67}
68
69/// Parses a trade message into a `TradeTick`.
70///
71/// # Errors
72///
73/// Returns an error if price or quantity fields cannot be parsed.
74pub fn parse_trade(
75    msg: &BinanceSpotTradeMsg,
76    instrument: &InstrumentAny,
77    ts_init: UnixNanos,
78) -> anyhow::Result<TradeTick> {
79    let instrument_id = instrument.id();
80    let price_precision = instrument.price_precision();
81    let size_precision = instrument.size_precision();
82
83    let price = parse_positive_price(&msg.price, price_precision, "trade price")?;
84    let size = parse_positive_quantity(&msg.quantity, size_precision, "trade quantity")?;
85
86    let aggressor_side = if msg.is_buyer_maker {
87        AggressorSide::Sell
88    } else {
89        AggressorSide::Buy
90    };
91
92    let ts_event = parse_millis_or_init(msg.trade_time, "Spot JSON trade time", ts_init);
93
94    Ok(TradeTick::new(
95        instrument_id,
96        price,
97        size,
98        aggressor_side,
99        TradeId::new(msg.trade_id.to_string()),
100        ts_event,
101        ts_init,
102    ))
103}
104
105/// Parses a book ticker message into a `QuoteTick`.
106///
107/// # Errors
108///
109/// Returns an error if price or quantity fields cannot be parsed.
110pub fn parse_book_ticker(
111    msg: &BinanceSpotBookTickerMsg,
112    instrument: &InstrumentAny,
113    ts_init: UnixNanos,
114) -> anyhow::Result<QuoteTick> {
115    let instrument_id = instrument.id();
116    let price_precision = instrument.price_precision();
117    let size_precision = instrument.size_precision();
118
119    let bid_price = parse_positive_price(&msg.best_bid_price, price_precision, "bid price")?;
120    // A side that empties reports a zero size, which is a valid quote state.
121    let bid_size = parse_non_negative_quantity(&msg.best_bid_qty, size_precision, "bid quantity")?;
122    let ask_price = parse_positive_price(&msg.best_ask_price, price_precision, "ask price")?;
123    let ask_size = parse_non_negative_quantity(&msg.best_ask_qty, size_precision, "ask quantity")?;
124
125    // Spot bookTicker payloads on public streams do not consistently include
126    // event timestamps; fall back to receive time when absent.
127    let ts_event = msg
128        .transaction_time
129        .or(msg.event_time)
130        .map_or(ts_init, |value| {
131            parse_millis_or_init(value, "Spot JSON book ticker time", ts_init)
132        });
133
134    Ok(QuoteTick::new(
135        instrument_id,
136        bid_price,
137        ask_price,
138        bid_size,
139        ask_size,
140        ts_event,
141        ts_init,
142    ))
143}
144
145/// Parses a partial depth snapshot message into `OrderBookDeltas`.
146///
147/// Returns `None` when there are no usable levels.
148pub fn parse_depth_snapshot(
149    msg: &BinanceSpotPartialDepthMsg,
150    instrument: &InstrumentAny,
151    ts_init: UnixNanos,
152) -> Option<OrderBookDeltas> {
153    let instrument_id = instrument.id();
154    let price_precision = instrument.price_precision();
155    let size_precision = instrument.size_precision();
156
157    let mut deltas = Vec::with_capacity(msg.bids.len() + msg.asks.len() + 1);
158    deltas.push(OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init));
159
160    for level in &msg.bids {
161        let Some(price) = parse_price_at_precision(&level[0], price_precision) else {
162            continue;
163        };
164        let Some(size) = parse_quantity_at_precision(&level[1], size_precision) else {
165            continue;
166        };
167
168        deltas.push(OrderBookDelta::new(
169            instrument_id,
170            BookAction::Add,
171            BookOrder::new(OrderSide::Buy, price, size, 0),
172            0,
173            0,
174            ts_init,
175            ts_init,
176        ));
177    }
178
179    for level in &msg.asks {
180        let Some(price) = parse_price_at_precision(&level[0], price_precision) else {
181            continue;
182        };
183        let Some(size) = parse_quantity_at_precision(&level[1], size_precision) else {
184            continue;
185        };
186
187        deltas.push(OrderBookDelta::new(
188            instrument_id,
189            BookAction::Add,
190            BookOrder::new(OrderSide::Sell, price, size, 0),
191            0,
192            0,
193            ts_init,
194            ts_init,
195        ));
196    }
197
198    if deltas.len() <= 1 {
199        return None;
200    }
201
202    // Mark the final emitted delta as the snapshot terminator. Assigning F_LAST by
203    // source index would drop the terminator whenever the last level fails to parse
204    // and is skipped above.
205    if let Some(last) = deltas.last_mut() {
206        last.flags |= RecordFlag::F_LAST as u8;
207    }
208
209    Some(OrderBookDeltas::new(instrument_id, deltas))
210}
211
212/// Parses a depth diff message into `OrderBookDeltas`.
213///
214/// # Errors
215///
216/// Returns an error if any price or quantity update cannot be parsed.
217pub fn parse_depth_diff(
218    msg: &BinanceSpotDepthDiffMsg,
219    instrument: &InstrumentAny,
220    ts_init: UnixNanos,
221) -> anyhow::Result<Option<OrderBookDeltas>> {
222    let instrument_id = instrument.id();
223    let price_precision = instrument.price_precision();
224    let size_precision = instrument.size_precision();
225    let ts_event = parse_millis_or_init(msg.event_time, "Spot JSON depth event time", ts_init);
226    let sequence = msg.final_update_id;
227
228    let mut deltas = Vec::with_capacity(msg.bids.len() + msg.asks.len());
229
230    for (i, level) in msg.bids.iter().enumerate() {
231        let price = parse_positive_price(&level[0], price_precision, "bid price")?;
232        let size = parse_non_negative_quantity(&level[1], size_precision, "bid quantity")?;
233        let action = if size.is_zero() {
234            BookAction::Delete
235        } else {
236            BookAction::Update
237        };
238        let flags = if i == msg.bids.len() - 1 && msg.asks.is_empty() {
239            RecordFlag::F_LAST as u8
240        } else {
241            0
242        };
243
244        deltas.push(OrderBookDelta::new(
245            instrument_id,
246            action,
247            BookOrder::new(OrderSide::Buy, price, size, 0),
248            flags,
249            sequence,
250            ts_event,
251            ts_init,
252        ));
253    }
254
255    for (i, level) in msg.asks.iter().enumerate() {
256        let price = parse_positive_price(&level[0], price_precision, "ask price")?;
257        let size = parse_non_negative_quantity(&level[1], size_precision, "ask quantity")?;
258        let action = if size.is_zero() {
259            BookAction::Delete
260        } else {
261            BookAction::Update
262        };
263        let flags = if i == msg.asks.len() - 1 {
264            RecordFlag::F_LAST as u8
265        } else {
266            0
267        };
268
269        deltas.push(OrderBookDelta::new(
270            instrument_id,
271            action,
272            BookOrder::new(OrderSide::Sell, price, size, 0),
273            flags,
274            sequence,
275            ts_event,
276            ts_init,
277        ));
278    }
279
280    if deltas.is_empty() {
281        return Ok(None);
282    }
283
284    Ok(Some(OrderBookDeltas::new(instrument_id, deltas)))
285}
286
287fn interval_to_bar_spec(interval: BinanceKlineInterval) -> BarSpecification {
288    match interval {
289        BinanceKlineInterval::Second1 => {
290            BarSpecification::new(1, BarAggregation::Second, PriceType::Last)
291        }
292        BinanceKlineInterval::Minute1 => {
293            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last)
294        }
295        BinanceKlineInterval::Minute3 => {
296            BarSpecification::new(3, BarAggregation::Minute, PriceType::Last)
297        }
298        BinanceKlineInterval::Minute5 => {
299            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last)
300        }
301        BinanceKlineInterval::Minute15 => {
302            BarSpecification::new(15, BarAggregation::Minute, PriceType::Last)
303        }
304        BinanceKlineInterval::Minute30 => {
305            BarSpecification::new(30, BarAggregation::Minute, PriceType::Last)
306        }
307        BinanceKlineInterval::Hour1 => {
308            BarSpecification::new(1, BarAggregation::Hour, PriceType::Last)
309        }
310        BinanceKlineInterval::Hour2 => {
311            BarSpecification::new(2, BarAggregation::Hour, PriceType::Last)
312        }
313        BinanceKlineInterval::Hour4 => {
314            BarSpecification::new(4, BarAggregation::Hour, PriceType::Last)
315        }
316        BinanceKlineInterval::Hour6 => {
317            BarSpecification::new(6, BarAggregation::Hour, PriceType::Last)
318        }
319        BinanceKlineInterval::Hour8 => {
320            BarSpecification::new(8, BarAggregation::Hour, PriceType::Last)
321        }
322        BinanceKlineInterval::Hour12 => {
323            BarSpecification::new(12, BarAggregation::Hour, PriceType::Last)
324        }
325        BinanceKlineInterval::Day1 => {
326            BarSpecification::new(1, BarAggregation::Day, PriceType::Last)
327        }
328        BinanceKlineInterval::Day3 => {
329            BarSpecification::new(3, BarAggregation::Day, PriceType::Last)
330        }
331        BinanceKlineInterval::Week1 => {
332            BarSpecification::new(1, BarAggregation::Week, PriceType::Last)
333        }
334        BinanceKlineInterval::Month1 => {
335            BarSpecification::new(1, BarAggregation::Month, PriceType::Last)
336        }
337    }
338}
339
340/// Parses a kline message into a closed `Bar`.
341///
342/// Returns `None` if the kline is not closed yet.
343///
344/// # Errors
345///
346/// Returns an error if any OHLCV field cannot be parsed.
347pub fn parse_kline(
348    msg: &BinanceSpotKlineMsg,
349    instrument: &InstrumentAny,
350    ts_init: UnixNanos,
351) -> anyhow::Result<Option<BinanceBar>> {
352    if !msg.kline.is_closed {
353        return Ok(None);
354    }
355
356    let instrument_id = instrument.id();
357    let price_precision = instrument.price_precision();
358    let size_precision = instrument.size_precision();
359
360    let spec = interval_to_bar_spec(msg.kline.interval);
361    let bar_type = BarType::new(instrument_id, spec, AggregationSource::External);
362
363    let open = parse_positive_price(&msg.kline.open, price_precision, "open price")?;
364    let high = parse_positive_price(&msg.kline.high, price_precision, "high price")?;
365    let low = parse_positive_price(&msg.kline.low, price_precision, "low price")?;
366    let close = parse_positive_price(&msg.kline.close, price_precision, "close price")?;
367    let volume = parse_non_negative_quantity(&msg.kline.volume, size_precision, "volume")?;
368    let quote_volume = Decimal::from_str(&msg.kline.quote_volume)
369        .with_context(|| format!("invalid quote volume `{}`", msg.kline.quote_volume))?;
370    let taker_buy_base_volume =
371        Decimal::from_str(&msg.kline.taker_buy_base_volume).with_context(|| {
372            format!(
373                "invalid taker buy base volume `{}`",
374                msg.kline.taker_buy_base_volume
375            )
376        })?;
377    let taker_buy_quote_volume = Decimal::from_str(&msg.kline.taker_buy_quote_volume)
378        .with_context(|| {
379            format!(
380                "invalid taker buy quote volume `{}`",
381                msg.kline.taker_buy_quote_volume
382            )
383        })?;
384    let count = u64::try_from(msg.kline.num_trades).map_err(|_| {
385        anyhow::anyhow!(
386            "invalid negative kline trade count {}",
387            msg.kline.num_trades
388        )
389    })?;
390
391    let ts_event =
392        parse_millis_or_init(msg.kline.close_time, "Spot JSON kline close time", ts_init);
393
394    Ok(Some(BinanceBar::new(
395        bar_type,
396        open,
397        high,
398        low,
399        close,
400        volume,
401        quote_volume,
402        count,
403        taker_buy_base_volume,
404        taker_buy_quote_volume,
405        ts_event,
406        ts_init,
407    )))
408}
409
410/// Parses a rolling 24-hour ticker message.
411///
412/// # Errors
413///
414/// Returns an error if any numeric field is invalid.
415pub fn parse_ticker(
416    msg: &BinanceSpotTickerMsg,
417    instrument: &InstrumentAny,
418    ts_init: UnixNanos,
419) -> anyhow::Result<BinanceSpotTicker> {
420    let decimal = |field: &str, value: &str| {
421        Decimal::from_str(value).with_context(|| format!("invalid {field} `{value}`"))
422    };
423    let millis = |field: &str, value: i64| parse_millis_or_init(value, field, ts_init);
424
425    Ok(BinanceSpotTicker {
426        instrument_id: instrument.id(),
427        price_change: decimal("price change", &msg.price_change)?,
428        price_change_percent: decimal("price change percent", &msg.price_change_percent)?,
429        weighted_avg_price: decimal("weighted average price", &msg.weighted_avg_price)?,
430        prev_close_price: decimal("previous close price", &msg.prev_close_price)?,
431        last_price: decimal("last price", &msg.last_price)?,
432        last_qty: decimal("last quantity", &msg.last_qty)?,
433        bid_price: decimal("bid price", &msg.bid_price)?,
434        bid_qty: decimal("bid quantity", &msg.bid_qty)?,
435        ask_price: decimal("ask price", &msg.ask_price)?,
436        ask_qty: decimal("ask quantity", &msg.ask_qty)?,
437        open_price: decimal("open price", &msg.open_price)?,
438        high_price: decimal("high price", &msg.high_price)?,
439        low_price: decimal("low price", &msg.low_price)?,
440        volume: decimal("volume", &msg.volume)?,
441        quote_volume: decimal("quote volume", &msg.quote_volume)?,
442        open_time: millis("Spot JSON ticker open time", msg.open_time),
443        close_time: millis("Spot JSON ticker close time", msg.close_time),
444        first_trade_id: msg.first_trade_id,
445        last_trade_id: msg.last_trade_id,
446        num_trades: msg.num_trades,
447        ts_event: millis("Spot JSON ticker event time", msg.event_time),
448        ts_init,
449    })
450}
451
452#[cfg(test)]
453mod tests {
454    use rstest::rstest;
455    use rust_decimal_macros::dec;
456    use ustr::Ustr;
457
458    use super::*;
459    use crate::{
460        common::parse::parse_spot_instrument_sbe,
461        spot::http::models::{
462            BinanceLotSizeFilterSbe, BinancePriceFilterSbe, BinanceSymbolFiltersSbe,
463            BinanceSymbolSbe,
464        },
465    };
466
467    fn sample_instrument() -> InstrumentAny {
468        let symbol = BinanceSymbolSbe {
469            symbol: "ETHUSDT".to_string(),
470            base_asset: "ETH".to_string(),
471            quote_asset: "USDT".to_string(),
472            base_asset_precision: 8,
473            quote_asset_precision: 8,
474            status: 0,
475            order_types: 0,
476            iceberg_allowed: true,
477            oco_allowed: true,
478            oto_allowed: false,
479            quote_order_qty_market_allowed: true,
480            allow_trailing_stop: true,
481            cancel_replace_allowed: true,
482            amend_allowed: true,
483            is_spot_trading_allowed: true,
484            is_margin_trading_allowed: false,
485            filters: BinanceSymbolFiltersSbe {
486                price_filter: Some(BinancePriceFilterSbe {
487                    price_exponent: -8,
488                    min_price: 1,
489                    max_price: 100_000_000_000_000,
490                    tick_size: 1,
491                }),
492                lot_size_filter: Some(BinanceLotSizeFilterSbe {
493                    qty_exponent: -8,
494                    min_qty: 1,
495                    max_qty: 900_000_000_000,
496                    step_size: 1,
497                }),
498            },
499            permissions: vec![vec!["SPOT".to_string()]],
500        };
501
502        let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
503        parse_spot_instrument_sbe(&symbol, ts, ts).unwrap()
504    }
505
506    #[rstest]
507    fn test_parse_trade_preserves_decimal_precision() {
508        let instrument = sample_instrument();
509        let msg = BinanceSpotTradeMsg {
510            event_type: "trade".to_string(),
511            event_time: 1_700_000_000_000,
512            symbol: Ustr::from("ETHUSDT"),
513            trade_id: 42,
514            price: "123.45678901".to_string(),
515            quantity: "0.10000001".to_string(),
516            trade_time: 1_700_000_000_001,
517            is_buyer_maker: false,
518        };
519
520        let tick = parse_trade(&msg, &instrument, UnixNanos::from(1)).unwrap();
521        assert_eq!(
522            tick.price.as_decimal(),
523            Decimal::from_str("123.45678901").unwrap()
524        );
525        assert_eq!(
526            tick.size.as_decimal(),
527            Decimal::from_str("0.10000001").unwrap()
528        );
529    }
530
531    #[rstest]
532    #[case::negative(-1)]
533    #[case::overflow(i64::MAX)]
534    fn test_parse_trade_falls_back_for_invalid_timestamp(#[case] trade_time: i64) {
535        let instrument = sample_instrument();
536        let msg = BinanceSpotTradeMsg {
537            event_type: "trade".to_string(),
538            event_time: 1_700_000_000_000,
539            symbol: Ustr::from("ETHUSDT"),
540            trade_id: 42,
541            price: "123.45678901".to_string(),
542            quantity: "0.10000001".to_string(),
543            trade_time,
544            is_buyer_maker: false,
545        };
546
547        let ts_init = UnixNanos::from(1);
548        let trade = parse_trade(&msg, &instrument, ts_init).unwrap();
549
550        assert_eq!(trade.ts_event, ts_init);
551        assert_eq!(trade.ts_init, ts_init);
552    }
553
554    #[rstest]
555    fn test_parse_book_ticker_preserves_decimal_precision() {
556        let instrument = sample_instrument();
557        let msg = BinanceSpotBookTickerMsg {
558            event_type: None,
559            event_time: None,
560            symbol: Ustr::from("ETHUSDT"),
561            book_update_id: 100,
562            best_bid_price: "123.45678901".to_string(),
563            best_bid_qty: "1.23000000".to_string(),
564            best_ask_price: "123.45678909".to_string(),
565            best_ask_qty: "4.56000000".to_string(),
566            transaction_time: Some(1_700_000_000_002),
567        };
568
569        let quote = parse_book_ticker(&msg, &instrument, UnixNanos::from(1)).unwrap();
570        assert_eq!(
571            quote.bid_price.as_decimal(),
572            Decimal::from_str("123.45678901").unwrap()
573        );
574        assert_eq!(
575            quote.ask_price.as_decimal(),
576            Decimal::from_str("123.45678909").unwrap()
577        );
578        assert_eq!(
579            quote.bid_size.as_decimal(),
580            Decimal::from_str("1.23000000").unwrap()
581        );
582        assert_eq!(
583            quote.ask_size.as_decimal(),
584            Decimal::from_str("4.56000000").unwrap()
585        );
586    }
587
588    #[rstest]
589    fn test_parse_book_ticker_accepts_zero_bid_size() {
590        let instrument = sample_instrument();
591        // A side that empties reports a zero size; the quote must still be produced.
592        let msg = BinanceSpotBookTickerMsg {
593            event_type: None,
594            event_time: None,
595            symbol: Ustr::from("ETHUSDT"),
596            book_update_id: 1,
597            best_bid_price: "100.00000000".to_string(),
598            best_bid_qty: "0.00000000".to_string(),
599            best_ask_price: "101.00000000".to_string(),
600            best_ask_qty: "1.00000000".to_string(),
601            transaction_time: None,
602        };
603
604        let quote = parse_book_ticker(&msg, &instrument, UnixNanos::from(1))
605            .expect("zero bid size is a valid quote");
606        assert_eq!(quote.bid_size.as_decimal(), Decimal::from_str("0").unwrap());
607    }
608
609    #[rstest]
610    fn test_parse_depth_snapshot_sets_last_flag_when_final_level_skipped() {
611        let instrument = sample_instrument();
612        // The final ask level has a zero quantity and is skipped during parsing; the
613        // F_LAST terminator must still land on the last emitted delta.
614        let msg = BinanceSpotPartialDepthMsg {
615            symbol: Ustr::from("ETHUSDT"),
616            last_update_id: 1,
617            bids: vec![["100.00000000".to_string(), "1.00000000".to_string()]],
618            asks: vec![
619                ["101.00000000".to_string(), "2.00000000".to_string()],
620                ["102.00000000".to_string(), "0.00000000".to_string()],
621            ],
622        };
623
624        let deltas = parse_depth_snapshot(&msg, &instrument, UnixNanos::from(1))
625            .expect("snapshot should produce deltas");
626
627        let last = deltas.deltas.last().expect("at least one delta");
628        assert_ne!(last.flags & RecordFlag::F_LAST as u8, 0);
629    }
630
631    #[rstest]
632    fn test_parse_depth_diff_sets_delete_actions_and_last_flag_on_final_ask() {
633        let instrument = sample_instrument();
634        let msg = BinanceSpotDepthDiffMsg {
635            event_type: "depthUpdate".to_string(),
636            event_time: 1_700_000_000_000,
637            symbol: Ustr::from("ETHUSDT"),
638            first_update_id: 10,
639            final_update_id: 12,
640            bids: vec![
641                ["100.00000000".to_string(), "1.00000000".to_string()],
642                ["99.00000000".to_string(), "0.00000000".to_string()],
643            ],
644            asks: vec![
645                ["101.00000000".to_string(), "2.00000000".to_string()],
646                ["102.00000000".to_string(), "0.00000000".to_string()],
647            ],
648        };
649
650        let deltas = parse_depth_diff(&msg, &instrument, UnixNanos::from(1))
651            .unwrap()
652            .expect("depth diff should produce deltas");
653
654        assert_eq!(deltas.sequence, 12);
655        assert_eq!(deltas.deltas.len(), 4);
656        assert_eq!(deltas.deltas[0].action, BookAction::Update);
657        assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy.into());
658        assert_eq!(deltas.deltas[0].flags, 0);
659        assert_eq!(deltas.deltas[1].action, BookAction::Delete);
660        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
661        assert_eq!(deltas.deltas[1].order.size.as_decimal(), Decimal::ZERO);
662        assert_eq!(deltas.deltas[1].flags, 0);
663        assert_eq!(deltas.deltas[2].action, BookAction::Update);
664        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell.into());
665        assert_eq!(deltas.deltas[2].flags, 0);
666        assert_eq!(deltas.deltas[3].action, BookAction::Delete);
667        assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell.into());
668        assert_eq!(deltas.deltas[3].order.size.as_decimal(), Decimal::ZERO);
669        assert_eq!(deltas.deltas[3].flags, RecordFlag::F_LAST as u8);
670    }
671
672    #[rstest]
673    fn test_parse_closed_one_second_kline_preserves_extended_fields() {
674        let instrument = sample_instrument();
675        let ts_init = UnixNanos::from(1_700_000_001_234_567_890_u64);
676        let msg = BinanceSpotKlineMsg {
677            event_type: "kline".to_string(),
678            event_time: 1_700_000_000_999,
679            symbol: Ustr::from("ETHUSDT"),
680            kline: super::super::messages::BinanceSpotKlineData {
681                start_time: 1_700_000_000_000,
682                close_time: 1_700_000_000_999,
683                symbol: Ustr::from("ETHUSDT"),
684                interval: BinanceKlineInterval::Second1,
685                first_trade_id: 201,
686                last_trade_id: 207,
687                open: "123.45678901".to_string(),
688                close: "124.56789012".to_string(),
689                high: "125.67890123".to_string(),
690                low: "122.34567890".to_string(),
691                volume: "7.65432109".to_string(),
692                num_trades: 7,
693                is_closed: true,
694                quote_volume: "951.35792468".to_string(),
695                taker_buy_base_volume: "3.21098765".to_string(),
696                taker_buy_quote_volume: "399.86420864".to_string(),
697            },
698        };
699
700        let bar = parse_kline(&msg, &instrument, ts_init).unwrap().unwrap();
701
702        assert_eq!(
703            bar.bar_type,
704            BarType::from("ETHUSDT.BINANCE-1-SECOND-LAST-EXTERNAL")
705        );
706        assert_eq!(bar.open, Price::from("123.45678901"));
707        assert_eq!(bar.high, Price::from("125.67890123"));
708        assert_eq!(bar.low, Price::from("122.34567890"));
709        assert_eq!(bar.close, Price::from("124.56789012"));
710        assert_eq!(bar.volume, Quantity::from("7.65432109"));
711        assert_eq!(bar.quote_volume, dec!(951.35792468));
712        assert_eq!(bar.count, 7);
713        assert_eq!(bar.taker_buy_base_volume, dec!(3.21098765));
714        assert_eq!(bar.taker_buy_quote_volume, dec!(399.86420864));
715        assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_000_999_000_000_u64));
716        assert_eq!(bar.ts_init, ts_init);
717    }
718
719    #[rstest]
720    fn test_parse_open_kline_returns_none() {
721        let instrument = sample_instrument();
722        let msg: BinanceSpotKlineMsg = serde_json::from_value(serde_json::json!({
723            "e": "kline",
724            "E": 1700000000999_i64,
725            "s": "ETHUSDT",
726            "k": {
727                "t": 1700000000000_i64,
728                "T": 1700000000999_i64,
729                "s": "ETHUSDT",
730                "i": "1s",
731                "f": 201,
732                "L": 207,
733                "o": "123.45678901",
734                "c": "124.56789012",
735                "h": "125.67890123",
736                "l": "122.34567890",
737                "v": "7.65432109",
738                "n": 7,
739                "x": false,
740                "q": "951.35792468",
741                "V": "3.21098765",
742                "Q": "399.86420864"
743            }
744        }))
745        .unwrap();
746
747        assert!(
748            parse_kline(&msg, &instrument, UnixNanos::from(1))
749                .unwrap()
750                .is_none()
751        );
752    }
753
754    #[rstest]
755    fn test_parse_spot_ticker_preserves_all_fields() {
756        let instrument = sample_instrument();
757        let ts_init = UnixNanos::from(1_700_000_001_234_567_890_u64);
758        let msg = BinanceSpotTickerMsg {
759            event_time: 1_700_000_000_999,
760            symbol: Ustr::from("ETHUSDT"),
761            price_change: "1.00000001".to_string(),
762            price_change_percent: "2.00000002".to_string(),
763            weighted_avg_price: "3.00000003".to_string(),
764            prev_close_price: "4.00000004".to_string(),
765            last_price: "5.00000005".to_string(),
766            last_qty: "6.00000006".to_string(),
767            bid_price: "7.00000007".to_string(),
768            bid_qty: "8.00000008".to_string(),
769            ask_price: "9.00000009".to_string(),
770            ask_qty: "10.00000010".to_string(),
771            open_price: "11.00000011".to_string(),
772            high_price: "12.00000012".to_string(),
773            low_price: "13.00000013".to_string(),
774            volume: "14.00000014".to_string(),
775            quote_volume: "15.00000015".to_string(),
776            open_time: 1_699_913_600_999,
777            close_time: 1_700_000_000_998,
778            first_trade_id: 301,
779            last_trade_id: 399,
780            num_trades: 99,
781        };
782
783        let ticker = parse_ticker(&msg, &instrument, ts_init).unwrap();
784
785        assert_eq!(ticker.instrument_id, instrument.id());
786        assert_eq!(ticker.price_change, dec!(1.00000001));
787        assert_eq!(ticker.price_change_percent, dec!(2.00000002));
788        assert_eq!(ticker.weighted_avg_price, dec!(3.00000003));
789        assert_eq!(ticker.prev_close_price, dec!(4.00000004));
790        assert_eq!(ticker.last_price, dec!(5.00000005));
791        assert_eq!(ticker.last_qty, dec!(6.00000006));
792        assert_eq!(ticker.bid_price, dec!(7.00000007));
793        assert_eq!(ticker.bid_qty, dec!(8.00000008));
794        assert_eq!(ticker.ask_price, dec!(9.00000009));
795        assert_eq!(ticker.ask_qty, dec!(10.00000010));
796        assert_eq!(ticker.open_price, dec!(11.00000011));
797        assert_eq!(ticker.high_price, dec!(12.00000012));
798        assert_eq!(ticker.low_price, dec!(13.00000013));
799        assert_eq!(ticker.volume, dec!(14.00000014));
800        assert_eq!(ticker.quote_volume, dec!(15.00000015));
801        assert_eq!(
802            ticker.open_time,
803            UnixNanos::from(1_699_913_600_999_000_000_u64)
804        );
805        assert_eq!(
806            ticker.close_time,
807            UnixNanos::from(1_700_000_000_998_000_000_u64)
808        );
809        assert_eq!(ticker.first_trade_id, 301);
810        assert_eq!(ticker.last_trade_id, 399);
811        assert_eq!(ticker.num_trades, 99);
812        assert_eq!(
813            ticker.ts_event,
814            UnixNanos::from(1_700_000_000_999_000_000_u64)
815        );
816        assert_eq!(ticker.ts_init, ts_init);
817    }
818
819    #[rstest]
820    fn test_parse_spot_ticker_rejects_invalid_decimal() {
821        let instrument = sample_instrument();
822        let mut msg: BinanceSpotTickerMsg = serde_json::from_value(serde_json::json!({
823            "E": 1700000000999_i64,
824            "s": "ETHUSDT",
825            "p": "1.1",
826            "P": "2.2",
827            "w": "3.3",
828            "x": "4.4",
829            "c": "5.5",
830            "Q": "6.6",
831            "b": "7.7",
832            "B": "8.8",
833            "a": "9.9",
834            "A": "10.1",
835            "o": "11.1",
836            "h": "12.1",
837            "l": "13.1",
838            "v": "14.1",
839            "q": "15.1",
840            "O": 1699913600999_i64,
841            "C": 1700000000998_i64,
842            "F": 301,
843            "L": 399,
844            "n": 99
845        }))
846        .unwrap();
847        msg.quote_volume = "invalid".to_string();
848
849        let error = parse_ticker(&msg, &instrument, UnixNanos::from(1)).unwrap_err();
850
851        assert!(error.to_string().contains("invalid quote volume `invalid`"));
852    }
853}