1use std::str::FromStr;
19
20use anyhow::Context;
21use nautilus_core::nanos::UnixNanos;
22use nautilus_model::{
23 data::{
24 Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDeltas, QuoteTick,
25 TradeTick,
26 },
27 enums::{
28 AggregationSource, AggressorSide, BarAggregation, BookAction, OrderSide, PriceType,
29 RecordFlag,
30 },
31 identifiers::TradeId,
32 instruments::{Instrument, InstrumentAny},
33 types::{Price, Quantity},
34};
35use rust_decimal::Decimal;
36
37use super::messages::{
38 BinanceSpotBookTickerMsg, BinanceSpotDepthDiffMsg, BinanceSpotKlineMsg,
39 BinanceSpotPartialDepthMsg, BinanceSpotTradeMsg,
40};
41use crate::common::{
42 enums::BinanceKlineInterval,
43 parse::{parse_price_at_precision, parse_quantity_at_precision},
44};
45
46fn parse_positive_price(raw: &str, precision: u8, field: &str) -> anyhow::Result<Price> {
47 parse_price_at_precision(raw, precision)
48 .ok_or_else(|| anyhow::anyhow!("invalid {field} `{raw}`"))
49}
50
51fn parse_positive_quantity(raw: &str, precision: u8, field: &str) -> anyhow::Result<Quantity> {
52 parse_quantity_at_precision(raw, precision)
53 .ok_or_else(|| anyhow::anyhow!("invalid {field} `{raw}`"))
54}
55
56fn parse_non_negative_quantity(raw: &str, precision: u8, field: &str) -> anyhow::Result<Quantity> {
57 let decimal = Decimal::from_str(raw).with_context(|| format!("invalid {field} `{raw}`"))?;
58 if decimal.is_sign_negative() {
59 anyhow::bail!("invalid {field} `{raw}`");
60 }
61
62 Quantity::from_decimal_dp(decimal, precision)
63 .map_err(|e| anyhow::anyhow!("invalid {field} `{raw}`: {e}"))
64}
65
66pub fn parse_trade(
72 msg: &BinanceSpotTradeMsg,
73 instrument: &InstrumentAny,
74 ts_init: UnixNanos,
75) -> anyhow::Result<TradeTick> {
76 let instrument_id = instrument.id();
77 let price_precision = instrument.price_precision();
78 let size_precision = instrument.size_precision();
79
80 let price = parse_positive_price(&msg.price, price_precision, "trade price")?;
81 let size = parse_positive_quantity(&msg.quantity, size_precision, "trade quantity")?;
82
83 let aggressor_side = if msg.is_buyer_maker {
84 AggressorSide::Seller
85 } else {
86 AggressorSide::Buyer
87 };
88
89 let ts_event = UnixNanos::from_millis(msg.trade_time as u64);
90
91 Ok(TradeTick::new(
92 instrument_id,
93 price,
94 size,
95 aggressor_side,
96 TradeId::new(msg.trade_id.to_string()),
97 ts_event,
98 ts_init,
99 ))
100}
101
102pub fn parse_book_ticker(
108 msg: &BinanceSpotBookTickerMsg,
109 instrument: &InstrumentAny,
110 ts_init: UnixNanos,
111) -> anyhow::Result<QuoteTick> {
112 let instrument_id = instrument.id();
113 let price_precision = instrument.price_precision();
114 let size_precision = instrument.size_precision();
115
116 let bid_price = parse_positive_price(&msg.best_bid_price, price_precision, "bid price")?;
117 let bid_size = parse_non_negative_quantity(&msg.best_bid_qty, size_precision, "bid quantity")?;
119 let ask_price = parse_positive_price(&msg.best_ask_price, price_precision, "ask price")?;
120 let ask_size = parse_non_negative_quantity(&msg.best_ask_qty, size_precision, "ask quantity")?;
121
122 let ts_event = msg
125 .transaction_time
126 .or(msg.event_time)
127 .and_then(|ts| u64::try_from(ts).ok())
128 .map_or(ts_init, UnixNanos::from_millis);
129
130 Ok(QuoteTick::new(
131 instrument_id,
132 bid_price,
133 ask_price,
134 bid_size,
135 ask_size,
136 ts_event,
137 ts_init,
138 ))
139}
140
141pub fn parse_depth_snapshot(
145 msg: &BinanceSpotPartialDepthMsg,
146 instrument: &InstrumentAny,
147 ts_init: UnixNanos,
148) -> Option<OrderBookDeltas> {
149 let instrument_id = instrument.id();
150 let price_precision = instrument.price_precision();
151 let size_precision = instrument.size_precision();
152
153 let mut deltas = Vec::with_capacity(msg.bids.len() + msg.asks.len() + 1);
154 deltas.push(OrderBookDelta::clear(instrument_id, 0, ts_init, ts_init));
155
156 for level in &msg.bids {
157 let Some(price) = parse_price_at_precision(&level[0], price_precision) else {
158 continue;
159 };
160 let Some(size) = parse_quantity_at_precision(&level[1], size_precision) else {
161 continue;
162 };
163
164 deltas.push(OrderBookDelta::new(
165 instrument_id,
166 BookAction::Add,
167 BookOrder::new(OrderSide::Buy, price, size, 0),
168 0,
169 0,
170 ts_init,
171 ts_init,
172 ));
173 }
174
175 for level in &msg.asks {
176 let Some(price) = parse_price_at_precision(&level[0], price_precision) else {
177 continue;
178 };
179 let Some(size) = parse_quantity_at_precision(&level[1], size_precision) else {
180 continue;
181 };
182
183 deltas.push(OrderBookDelta::new(
184 instrument_id,
185 BookAction::Add,
186 BookOrder::new(OrderSide::Sell, price, size, 0),
187 0,
188 0,
189 ts_init,
190 ts_init,
191 ));
192 }
193
194 if deltas.len() <= 1 {
195 return None;
196 }
197
198 if let Some(last) = deltas.last_mut() {
202 last.flags |= RecordFlag::F_LAST as u8;
203 }
204
205 Some(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 = UnixNanos::from_millis(msg.event_time as u64);
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<Bar>> {
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
365 let ts_event = UnixNanos::from_millis(msg.kline.close_time as u64);
366
367 Ok(Some(Bar::new(
368 bar_type, open, high, low, close, volume, ts_event, ts_init,
369 )))
370}
371
372#[cfg(test)]
373mod tests {
374 use rstest::rstest;
375 use ustr::Ustr;
376
377 use super::*;
378 use crate::{
379 common::parse::parse_spot_instrument_sbe,
380 spot::http::models::{
381 BinanceLotSizeFilterSbe, BinancePriceFilterSbe, BinanceSymbolFiltersSbe,
382 BinanceSymbolSbe,
383 },
384 };
385
386 fn sample_instrument() -> InstrumentAny {
387 let symbol = BinanceSymbolSbe {
388 symbol: "ETHUSDT".to_string(),
389 base_asset: "ETH".to_string(),
390 quote_asset: "USDT".to_string(),
391 base_asset_precision: 8,
392 quote_asset_precision: 8,
393 status: 0,
394 order_types: 0,
395 iceberg_allowed: true,
396 oco_allowed: true,
397 oto_allowed: false,
398 quote_order_qty_market_allowed: true,
399 allow_trailing_stop: true,
400 cancel_replace_allowed: true,
401 amend_allowed: true,
402 is_spot_trading_allowed: true,
403 is_margin_trading_allowed: false,
404 filters: BinanceSymbolFiltersSbe {
405 price_filter: Some(BinancePriceFilterSbe {
406 price_exponent: -8,
407 min_price: 1,
408 max_price: 100_000_000_000_000,
409 tick_size: 1,
410 }),
411 lot_size_filter: Some(BinanceLotSizeFilterSbe {
412 qty_exponent: -8,
413 min_qty: 1,
414 max_qty: 900_000_000_000,
415 step_size: 1,
416 }),
417 },
418 permissions: vec![vec!["SPOT".to_string()]],
419 };
420
421 let ts = UnixNanos::from(1_700_000_000_000_000_000u64);
422 parse_spot_instrument_sbe(&symbol, ts, ts).unwrap()
423 }
424
425 #[rstest]
426 fn test_parse_trade_preserves_decimal_precision() {
427 let instrument = sample_instrument();
428 let msg = BinanceSpotTradeMsg {
429 event_type: "trade".to_string(),
430 event_time: 1_700_000_000_000,
431 symbol: Ustr::from("ETHUSDT"),
432 trade_id: 42,
433 price: "123.45678901".to_string(),
434 quantity: "0.10000001".to_string(),
435 trade_time: 1_700_000_000_001,
436 is_buyer_maker: false,
437 };
438
439 let tick = parse_trade(&msg, &instrument, UnixNanos::from(1)).unwrap();
440 assert_eq!(
441 tick.price.as_decimal(),
442 Decimal::from_str("123.45678901").unwrap()
443 );
444 assert_eq!(
445 tick.size.as_decimal(),
446 Decimal::from_str("0.10000001").unwrap()
447 );
448 }
449
450 #[rstest]
451 fn test_parse_book_ticker_preserves_decimal_precision() {
452 let instrument = sample_instrument();
453 let msg = BinanceSpotBookTickerMsg {
454 event_type: None,
455 event_time: None,
456 symbol: Ustr::from("ETHUSDT"),
457 book_update_id: 100,
458 best_bid_price: "123.45678901".to_string(),
459 best_bid_qty: "1.23000000".to_string(),
460 best_ask_price: "123.45678909".to_string(),
461 best_ask_qty: "4.56000000".to_string(),
462 transaction_time: Some(1_700_000_000_002),
463 };
464
465 let quote = parse_book_ticker(&msg, &instrument, UnixNanos::from(1)).unwrap();
466 assert_eq!(
467 quote.bid_price.as_decimal(),
468 Decimal::from_str("123.45678901").unwrap()
469 );
470 assert_eq!(
471 quote.ask_price.as_decimal(),
472 Decimal::from_str("123.45678909").unwrap()
473 );
474 assert_eq!(
475 quote.bid_size.as_decimal(),
476 Decimal::from_str("1.23000000").unwrap()
477 );
478 assert_eq!(
479 quote.ask_size.as_decimal(),
480 Decimal::from_str("4.56000000").unwrap()
481 );
482 }
483
484 #[rstest]
485 fn test_parse_book_ticker_accepts_zero_bid_size() {
486 let instrument = sample_instrument();
487 let msg = BinanceSpotBookTickerMsg {
489 event_type: None,
490 event_time: None,
491 symbol: Ustr::from("ETHUSDT"),
492 book_update_id: 1,
493 best_bid_price: "100.00000000".to_string(),
494 best_bid_qty: "0.00000000".to_string(),
495 best_ask_price: "101.00000000".to_string(),
496 best_ask_qty: "1.00000000".to_string(),
497 transaction_time: None,
498 };
499
500 let quote = parse_book_ticker(&msg, &instrument, UnixNanos::from(1))
501 .expect("zero bid size is a valid quote");
502 assert_eq!(quote.bid_size.as_decimal(), Decimal::from_str("0").unwrap());
503 }
504
505 #[rstest]
506 fn test_parse_depth_snapshot_sets_last_flag_when_final_level_skipped() {
507 let instrument = sample_instrument();
508 let msg = BinanceSpotPartialDepthMsg {
511 symbol: Ustr::from("ETHUSDT"),
512 last_update_id: 1,
513 bids: vec![["100.00000000".to_string(), "1.00000000".to_string()]],
514 asks: vec![
515 ["101.00000000".to_string(), "2.00000000".to_string()],
516 ["102.00000000".to_string(), "0.00000000".to_string()],
517 ],
518 };
519
520 let deltas = parse_depth_snapshot(&msg, &instrument, UnixNanos::from(1))
521 .expect("snapshot should produce deltas");
522
523 let last = deltas.deltas.last().expect("at least one delta");
524 assert_ne!(last.flags & RecordFlag::F_LAST as u8, 0);
525 }
526
527 #[rstest]
528 fn test_parse_depth_diff_sets_delete_actions_and_last_flag_on_final_ask() {
529 let instrument = sample_instrument();
530 let msg = BinanceSpotDepthDiffMsg {
531 event_type: "depthUpdate".to_string(),
532 event_time: 1_700_000_000_000,
533 symbol: Ustr::from("ETHUSDT"),
534 first_update_id: 10,
535 final_update_id: 12,
536 bids: vec![
537 ["100.00000000".to_string(), "1.00000000".to_string()],
538 ["99.00000000".to_string(), "0.00000000".to_string()],
539 ],
540 asks: vec![
541 ["101.00000000".to_string(), "2.00000000".to_string()],
542 ["102.00000000".to_string(), "0.00000000".to_string()],
543 ],
544 };
545
546 let deltas = parse_depth_diff(&msg, &instrument, UnixNanos::from(1))
547 .unwrap()
548 .expect("depth diff should produce deltas");
549
550 assert_eq!(deltas.sequence, 12);
551 assert_eq!(deltas.deltas.len(), 4);
552 assert_eq!(deltas.deltas[0].action, BookAction::Update);
553 assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy);
554 assert_eq!(deltas.deltas[0].flags, 0);
555 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
556 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
557 assert_eq!(deltas.deltas[1].order.size.as_decimal(), Decimal::ZERO);
558 assert_eq!(deltas.deltas[1].flags, 0);
559 assert_eq!(deltas.deltas[2].action, BookAction::Update);
560 assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell);
561 assert_eq!(deltas.deltas[2].flags, 0);
562 assert_eq!(deltas.deltas[3].action, BookAction::Delete);
563 assert_eq!(deltas.deltas[3].order.side, OrderSide::Sell);
564 assert_eq!(deltas.deltas[3].order.size.as_decimal(), Decimal::ZERO);
565 assert_eq!(deltas.deltas[3].flags, RecordFlag::F_LAST as u8);
566 }
567}