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 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 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 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}