1use 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
69pub 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
105pub 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 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 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
145pub 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 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
212pub 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
340pub 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
410pub 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 notional_filters: Vec::new(),
487 price_filter: Some(BinancePriceFilterSbe {
488 price_exponent: -8,
489 min_price: 1,
490 max_price: 100_000_000_000_000,
491 tick_size: 1,
492 }),
493 lot_size_filter: Some(BinanceLotSizeFilterSbe {
494 qty_exponent: -8,
495 min_qty: 1,
496 max_qty: 900_000_000_000,
497 step_size: 1,
498 }),
499 },
500 permissions: vec![vec!["SPOT".to_string()]],
501 };
502
503 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
504 parse_spot_instrument_sbe(&symbol, ts, ts).unwrap()
505 }
506
507 #[rstest]
508 fn test_parse_trade_preserves_decimal_precision() {
509 let instrument = sample_instrument();
510 let msg = BinanceSpotTradeMsg {
511 event_type: "trade".to_string(),
512 event_time: 1_700_000_000_000,
513 symbol: Ustr::from("ETHUSDT"),
514 trade_id: 42,
515 price: "123.45678901".to_string(),
516 quantity: "0.10000001".to_string(),
517 trade_time: 1_700_000_000_001,
518 is_buyer_maker: false,
519 };
520
521 let tick = parse_trade(&msg, &instrument, UnixNanos::from(1)).unwrap();
522 assert_eq!(
523 tick.price.as_decimal(),
524 Decimal::from_str("123.45678901").unwrap()
525 );
526 assert_eq!(
527 tick.size.as_decimal(),
528 Decimal::from_str("0.10000001").unwrap()
529 );
530 }
531
532 #[rstest]
533 #[case::negative(-1)]
534 #[case::overflow(i64::MAX)]
535 fn test_parse_trade_falls_back_for_invalid_timestamp(#[case] trade_time: i64) {
536 let instrument = sample_instrument();
537 let msg = BinanceSpotTradeMsg {
538 event_type: "trade".to_string(),
539 event_time: 1_700_000_000_000,
540 symbol: Ustr::from("ETHUSDT"),
541 trade_id: 42,
542 price: "123.45678901".to_string(),
543 quantity: "0.10000001".to_string(),
544 trade_time,
545 is_buyer_maker: false,
546 };
547
548 let ts_init = UnixNanos::from(1);
549 let trade = parse_trade(&msg, &instrument, ts_init).unwrap();
550
551 assert_eq!(trade.ts_event, ts_init);
552 assert_eq!(trade.ts_init, ts_init);
553 }
554
555 #[rstest]
556 fn test_parse_book_ticker_preserves_decimal_precision() {
557 let instrument = sample_instrument();
558 let msg = BinanceSpotBookTickerMsg {
559 event_type: None,
560 event_time: None,
561 symbol: Ustr::from("ETHUSDT"),
562 book_update_id: 100,
563 best_bid_price: "123.45678901".to_string(),
564 best_bid_qty: "1.23000000".to_string(),
565 best_ask_price: "123.45678909".to_string(),
566 best_ask_qty: "4.56000000".to_string(),
567 transaction_time: Some(1_700_000_000_002),
568 };
569
570 let quote = parse_book_ticker(&msg, &instrument, UnixNanos::from(1)).unwrap();
571 assert_eq!(
572 quote.bid_price.as_decimal(),
573 Decimal::from_str("123.45678901").unwrap()
574 );
575 assert_eq!(
576 quote.ask_price.as_decimal(),
577 Decimal::from_str("123.45678909").unwrap()
578 );
579 assert_eq!(
580 quote.bid_size.as_decimal(),
581 Decimal::from_str("1.23000000").unwrap()
582 );
583 assert_eq!(
584 quote.ask_size.as_decimal(),
585 Decimal::from_str("4.56000000").unwrap()
586 );
587 }
588
589 #[rstest]
590 fn test_parse_book_ticker_accepts_zero_bid_size() {
591 let instrument = sample_instrument();
592 let msg = BinanceSpotBookTickerMsg {
594 event_type: None,
595 event_time: None,
596 symbol: Ustr::from("ETHUSDT"),
597 book_update_id: 1,
598 best_bid_price: "100.00000000".to_string(),
599 best_bid_qty: "0.00000000".to_string(),
600 best_ask_price: "101.00000000".to_string(),
601 best_ask_qty: "1.00000000".to_string(),
602 transaction_time: None,
603 };
604
605 let quote = parse_book_ticker(&msg, &instrument, UnixNanos::from(1))
606 .expect("zero bid size is a valid quote");
607 assert_eq!(quote.bid_size.as_decimal(), Decimal::from_str("0").unwrap());
608 }
609
610 #[rstest]
611 fn test_parse_depth_snapshot_sets_last_flag_when_final_level_skipped() {
612 let instrument = sample_instrument();
613 let msg = BinanceSpotPartialDepthMsg {
616 symbol: Ustr::from("ETHUSDT"),
617 last_update_id: 1,
618 bids: vec![["100.00000000".to_string(), "1.00000000".to_string()]],
619 asks: vec![
620 ["101.00000000".to_string(), "2.00000000".to_string()],
621 ["102.00000000".to_string(), "0.00000000".to_string()],
622 ],
623 };
624
625 let deltas = parse_depth_snapshot(&msg, &instrument, UnixNanos::from(1))
626 .expect("snapshot should produce deltas");
627
628 let last = deltas.deltas.last().expect("at least one delta");
629 assert_ne!(last.flags & RecordFlag::F_LAST as u8, 0);
630 }
631
632 #[rstest]
633 fn test_parse_depth_diff_sets_delete_actions_and_last_flag_on_final_ask() {
634 let instrument = sample_instrument();
635 let msg = BinanceSpotDepthDiffMsg {
636 event_type: "depthUpdate".to_string(),
637 event_time: 1_700_000_000_000,
638 symbol: Ustr::from("ETHUSDT"),
639 first_update_id: 10,
640 final_update_id: 12,
641 bids: vec![
642 ["100.00000000".to_string(), "1.00000000".to_string()],
643 ["99.00000000".to_string(), "0.00000000".to_string()],
644 ],
645 asks: vec![
646 ["101.00000000".to_string(), "2.00000000".to_string()],
647 ["102.00000000".to_string(), "0.00000000".to_string()],
648 ],
649 };
650
651 let deltas = parse_depth_diff(&msg, &instrument, UnixNanos::from(1))
652 .unwrap()
653 .expect("depth diff should produce deltas");
654
655 assert_eq!(deltas.sequence, 12);
656 assert_eq!(deltas.deltas.len(), 4);
657 assert_eq!(deltas.deltas[0].action, BookAction::Update);
658 assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy.into());
659 assert_eq!(deltas.deltas[0].flags, 0);
660 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
661 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
662 assert_eq!(deltas.deltas[1].order.size.as_decimal(), Decimal::ZERO);
663 assert_eq!(deltas.deltas[1].flags, 0);
664 assert_eq!(deltas.deltas[2].action, BookAction::Update);
665 assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell.into());
666 assert_eq!(deltas.deltas[2].flags, 0);
667 assert_eq!(deltas.deltas[3].action, BookAction::Delete);
668 assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell.into());
669 assert_eq!(deltas.deltas[3].order.size.as_decimal(), Decimal::ZERO);
670 assert_eq!(deltas.deltas[3].flags, RecordFlag::F_LAST as u8);
671 }
672
673 #[rstest]
674 fn test_parse_closed_one_second_kline_preserves_extended_fields() {
675 let instrument = sample_instrument();
676 let ts_init = UnixNanos::from(1_700_000_001_234_567_890_u64);
677 let msg = BinanceSpotKlineMsg {
678 event_type: "kline".to_string(),
679 event_time: 1_700_000_000_999,
680 symbol: Ustr::from("ETHUSDT"),
681 kline: super::super::messages::BinanceSpotKlineData {
682 start_time: 1_700_000_000_000,
683 close_time: 1_700_000_000_999,
684 symbol: Ustr::from("ETHUSDT"),
685 interval: BinanceKlineInterval::Second1,
686 first_trade_id: 201,
687 last_trade_id: 207,
688 open: "123.45678901".to_string(),
689 close: "124.56789012".to_string(),
690 high: "125.67890123".to_string(),
691 low: "122.34567890".to_string(),
692 volume: "7.65432109".to_string(),
693 num_trades: 7,
694 is_closed: true,
695 quote_volume: "951.35792468".to_string(),
696 taker_buy_base_volume: "3.21098765".to_string(),
697 taker_buy_quote_volume: "399.86420864".to_string(),
698 },
699 };
700
701 let bar = parse_kline(&msg, &instrument, ts_init).unwrap().unwrap();
702
703 assert_eq!(
704 bar.bar_type,
705 BarType::from("ETHUSDT.BINANCE-1-SECOND-LAST-EXTERNAL")
706 );
707 assert_eq!(bar.open, Price::from("123.45678901"));
708 assert_eq!(bar.high, Price::from("125.67890123"));
709 assert_eq!(bar.low, Price::from("122.34567890"));
710 assert_eq!(bar.close, Price::from("124.56789012"));
711 assert_eq!(bar.volume, Quantity::from("7.65432109"));
712 assert_eq!(bar.quote_volume, dec!(951.35792468));
713 assert_eq!(bar.count, 7);
714 assert_eq!(bar.taker_buy_base_volume, dec!(3.21098765));
715 assert_eq!(bar.taker_buy_quote_volume, dec!(399.86420864));
716 assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_000_999_000_000_u64));
717 assert_eq!(bar.ts_init, ts_init);
718 }
719
720 #[rstest]
721 fn test_parse_open_kline_returns_none() {
722 let instrument = sample_instrument();
723 let msg: BinanceSpotKlineMsg = serde_json::from_value(serde_json::json!({
724 "e": "kline",
725 "E": 1700000000999_i64,
726 "s": "ETHUSDT",
727 "k": {
728 "t": 1700000000000_i64,
729 "T": 1700000000999_i64,
730 "s": "ETHUSDT",
731 "i": "1s",
732 "f": 201,
733 "L": 207,
734 "o": "123.45678901",
735 "c": "124.56789012",
736 "h": "125.67890123",
737 "l": "122.34567890",
738 "v": "7.65432109",
739 "n": 7,
740 "x": false,
741 "q": "951.35792468",
742 "V": "3.21098765",
743 "Q": "399.86420864"
744 }
745 }))
746 .unwrap();
747
748 assert!(
749 parse_kline(&msg, &instrument, UnixNanos::from(1))
750 .unwrap()
751 .is_none()
752 );
753 }
754
755 #[rstest]
756 fn test_parse_spot_ticker_preserves_all_fields() {
757 let instrument = sample_instrument();
758 let ts_init = UnixNanos::from(1_700_000_001_234_567_890_u64);
759 let msg = BinanceSpotTickerMsg {
760 event_time: 1_700_000_000_999,
761 symbol: Ustr::from("ETHUSDT"),
762 price_change: "1.00000001".to_string(),
763 price_change_percent: "2.00000002".to_string(),
764 weighted_avg_price: "3.00000003".to_string(),
765 prev_close_price: "4.00000004".to_string(),
766 last_price: "5.00000005".to_string(),
767 last_qty: "6.00000006".to_string(),
768 bid_price: "7.00000007".to_string(),
769 bid_qty: "8.00000008".to_string(),
770 ask_price: "9.00000009".to_string(),
771 ask_qty: "10.00000010".to_string(),
772 open_price: "11.00000011".to_string(),
773 high_price: "12.00000012".to_string(),
774 low_price: "13.00000013".to_string(),
775 volume: "14.00000014".to_string(),
776 quote_volume: "15.00000015".to_string(),
777 open_time: 1_699_913_600_999,
778 close_time: 1_700_000_000_998,
779 first_trade_id: 301,
780 last_trade_id: 399,
781 num_trades: 99,
782 };
783
784 let ticker = parse_ticker(&msg, &instrument, ts_init).unwrap();
785
786 assert_eq!(ticker.instrument_id, instrument.id());
787 assert_eq!(ticker.price_change, dec!(1.00000001));
788 assert_eq!(ticker.price_change_percent, dec!(2.00000002));
789 assert_eq!(ticker.weighted_avg_price, dec!(3.00000003));
790 assert_eq!(ticker.prev_close_price, dec!(4.00000004));
791 assert_eq!(ticker.last_price, dec!(5.00000005));
792 assert_eq!(ticker.last_qty, dec!(6.00000006));
793 assert_eq!(ticker.bid_price, dec!(7.00000007));
794 assert_eq!(ticker.bid_qty, dec!(8.00000008));
795 assert_eq!(ticker.ask_price, dec!(9.00000009));
796 assert_eq!(ticker.ask_qty, dec!(10.00000010));
797 assert_eq!(ticker.open_price, dec!(11.00000011));
798 assert_eq!(ticker.high_price, dec!(12.00000012));
799 assert_eq!(ticker.low_price, dec!(13.00000013));
800 assert_eq!(ticker.volume, dec!(14.00000014));
801 assert_eq!(ticker.quote_volume, dec!(15.00000015));
802 assert_eq!(
803 ticker.open_time,
804 UnixNanos::from(1_699_913_600_999_000_000_u64)
805 );
806 assert_eq!(
807 ticker.close_time,
808 UnixNanos::from(1_700_000_000_998_000_000_u64)
809 );
810 assert_eq!(ticker.first_trade_id, 301);
811 assert_eq!(ticker.last_trade_id, 399);
812 assert_eq!(ticker.num_trades, 99);
813 assert_eq!(
814 ticker.ts_event,
815 UnixNanos::from(1_700_000_000_999_000_000_u64)
816 );
817 assert_eq!(ticker.ts_init, ts_init);
818 }
819
820 #[rstest]
821 fn test_parse_spot_ticker_rejects_invalid_decimal() {
822 let instrument = sample_instrument();
823 let mut msg: BinanceSpotTickerMsg = serde_json::from_value(serde_json::json!({
824 "E": 1700000000999_i64,
825 "s": "ETHUSDT",
826 "p": "1.1",
827 "P": "2.2",
828 "w": "3.3",
829 "x": "4.4",
830 "c": "5.5",
831 "Q": "6.6",
832 "b": "7.7",
833 "B": "8.8",
834 "a": "9.9",
835 "A": "10.1",
836 "o": "11.1",
837 "h": "12.1",
838 "l": "13.1",
839 "v": "14.1",
840 "q": "15.1",
841 "O": 1699913600999_i64,
842 "C": 1700000000998_i64,
843 "F": 301,
844 "L": 399,
845 "n": 99
846 }))
847 .unwrap();
848 msg.quote_volume = "invalid".to_string();
849
850 let error = parse_ticker(&msg, &instrument, UnixNanos::from(1)).unwrap_err();
851
852 assert!(error.to_string().contains("invalid quote volume `invalid`"));
853 }
854}