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) -> 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 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
208pub 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
336pub 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
406pub 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 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 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}