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