1use rust_decimal::Decimal;
24use ustr::Ustr;
25
26use super::user_data::{
27 BinanceSpotAccountPositionMsg, BinanceSpotBalanceEntry, BinanceSpotBalanceUpdateMsg,
28 BinanceSpotExecutionReport, BinanceSpotExecutionType,
29};
30use crate::{
31 common::enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
32 spot::sbe::spot::{
33 ReadBuf, balance_update_event_codec, bool_enum, execution_report_event_codec,
34 execution_type, expiry_reason, message_header_codec, order_side, order_status, order_type,
35 outbound_account_position_event_codec, time_in_force,
36 },
37};
38
39const HEADER_LEN: usize = message_header_codec::ENCODED_LENGTH;
40
41pub fn decode_execution_report(data: &[u8]) -> anyhow::Result<BinanceSpotExecutionReport> {
50 if data.len() < HEADER_LEN {
51 anyhow::bail!(
52 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
53 data.len()
54 );
55 }
56
57 let buf = ReadBuf::new(data);
58 let block_length = buf.get_u16_at(0);
59 let template_id = buf.get_u16_at(2);
60 let schema_id = buf.get_u16_at(4);
61 let version = buf.get_u16_at(6);
62
63 if template_id != execution_report_event_codec::SBE_TEMPLATE_ID {
64 anyhow::bail!(
65 "Wrong template ID: expected {}, received {template_id}",
66 execution_report_event_codec::SBE_TEMPLATE_ID
67 );
68 }
69
70 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
71 anyhow::bail!(
72 "Wrong schema ID: expected {}, received {schema_id}",
73 crate::spot::sbe::spot::SBE_SCHEMA_ID
74 );
75 }
76
77 let min_len = HEADER_LEN + block_length as usize;
78 if data.len() < min_len {
79 anyhow::bail!(
80 "Buffer too short for fixed block: expected {min_len}, was {}",
81 data.len()
82 );
83 }
84
85 let mut dec = execution_report_event_codec::ExecutionReportEventDecoder::default().wrap(
86 buf,
87 HEADER_LEN,
88 block_length,
89 version,
90 );
91
92 let price_exp = dec.price_exponent();
93 let qty_exp = dec.qty_exponent();
94 let commission_exp = dec.commission_exponent();
95
96 let event_time_us = dec.event_time();
97 let transact_time_us = dec.transact_time();
98 let order_creation_time_us = dec.order_creation_time();
99 let order_id = dec.order_id();
100 let trade_id = dec.trade_id().unwrap_or(-1);
101
102 let execution_type = map_execution_type(dec.execution_type())?;
103 let order_status = map_order_status(dec.order_status());
104 let side = map_side(dec.side())?;
105 let time_in_force = map_time_in_force(dec.time_in_force());
106 let order_type_str = map_order_type(dec.order_type());
107 let expiry_reason = map_expiry_reason(dec.expiry_reason());
108 let is_working = dec.is_working() == bool_enum::BoolEnum::True;
109 let is_maker = dec.is_maker() == bool_enum::BoolEnum::True;
110
111 let price_mantissa = dec.price();
112 let orig_qty_mantissa = dec.orig_qty();
113 let stop_price_mantissa = dec.stop_price();
114 let last_qty_mantissa = dec.last_qty();
115 let last_price_mantissa = dec.last_price();
116 let executed_qty_mantissa = dec.executed_qty();
117 let cummulative_quote_qty_mantissa = dec.cummulative_quote_qty();
118 let commission_mantissa = dec.commission();
119
120 let symbol = {
123 let coords = dec.symbol_decoder();
124 String::from_utf8_lossy(dec.symbol_slice(coords)).into_owned()
125 };
126
127 let client_order_id = {
128 let coords = dec.client_order_id_decoder();
129 String::from_utf8_lossy(dec.client_order_id_slice(coords)).into_owned()
130 };
131
132 let orig_client_order_id = {
133 let coords = dec.orig_client_order_id_decoder();
134 let s = String::from_utf8_lossy(dec.orig_client_order_id_slice(coords)).into_owned();
135 if s.is_empty() { None } else { Some(s) }
136 };
137
138 let commission_asset = {
139 let coords = dec.commission_asset_decoder();
140 let bytes = dec.commission_asset_slice(coords);
141 if bytes.is_empty() {
142 None
143 } else {
144 Some(Ustr::from(&String::from_utf8_lossy(bytes)))
145 }
146 };
147
148 let reject_reason = {
149 let coords = dec.reject_reason_decoder();
150 String::from_utf8_lossy(dec.reject_reason_slice(coords)).into_owned()
151 };
152
153 let _counter_symbol_coords = dec.counter_symbol_decoder();
155
156 Ok(BinanceSpotExecutionReport {
157 event_type: "executionReport".to_string(),
158 event_time: us_to_ms(event_time_us),
159 symbol: Ustr::from(&symbol),
160 client_order_id,
161 side,
162 order_type: order_type_str.to_string(),
163 time_in_force,
164 original_qty: mantissa_to_decimal_string(orig_qty_mantissa, qty_exp),
165 price: mantissa_to_decimal_string(price_mantissa, price_exp),
166 stop_price: mantissa_to_decimal_string(stop_price_mantissa, price_exp),
167 execution_type,
168 order_status,
169 reject_reason,
170 order_id,
171 last_filled_qty: mantissa_to_decimal_string(last_qty_mantissa, qty_exp),
172 cumulative_filled_qty: mantissa_to_decimal_string(executed_qty_mantissa, qty_exp),
173 last_filled_price: mantissa_to_decimal_string(last_price_mantissa, price_exp),
174 commission: mantissa_to_decimal_string(commission_mantissa, commission_exp),
175 commission_asset,
176 transaction_time: us_to_ms(transact_time_us),
177 trade_id,
178 is_working,
179 is_maker,
180 order_creation_time: order_creation_time_us.map_or(0, us_to_ms),
181 cumulative_quote_qty: mantissa_to_decimal_string(
182 cummulative_quote_qty_mantissa,
183 price_exp + qty_exp,
184 ),
185 original_client_order_id: orig_client_order_id,
186 expiry_reason,
187 })
188}
189
190pub fn decode_account_position(data: &[u8]) -> anyhow::Result<BinanceSpotAccountPositionMsg> {
200 if data.len() < HEADER_LEN {
201 anyhow::bail!(
202 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
203 data.len()
204 );
205 }
206
207 let buf = ReadBuf::new(data);
208 let block_length = buf.get_u16_at(0);
209 let template_id = buf.get_u16_at(2);
210 let schema_id = buf.get_u16_at(4);
211 let version = buf.get_u16_at(6);
212
213 if template_id != outbound_account_position_event_codec::SBE_TEMPLATE_ID {
214 anyhow::bail!(
215 "Wrong template ID: expected {}, received {template_id}",
216 outbound_account_position_event_codec::SBE_TEMPLATE_ID
217 );
218 }
219
220 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
221 anyhow::bail!(
222 "Wrong schema ID: expected {}, received {schema_id}",
223 crate::spot::sbe::spot::SBE_SCHEMA_ID
224 );
225 }
226
227 let min_len = HEADER_LEN + block_length as usize;
228 if data.len() < min_len {
229 anyhow::bail!(
230 "Buffer too short for fixed block: expected {min_len}, was {}",
231 data.len()
232 );
233 }
234
235 let dec = outbound_account_position_event_codec::OutboundAccountPositionEventDecoder::default()
236 .wrap(buf, HEADER_LEN, block_length, version);
237
238 let event_time_us = dec.event_time();
239 let update_time_us = dec.update_time();
240
241 let mut balances_dec = dec.balances_decoder();
242 let count = balances_dec.count() as usize;
243 let mut balances = Vec::with_capacity(count);
244
245 while let Some(_idx) = balances_dec
246 .advance()
247 .map_err(|e| anyhow::anyhow!("Failed to advance balances group: {e:?}"))?
248 {
249 let exponent = balances_dec.exponent();
250 let free_mantissa = balances_dec.free();
251 let locked_mantissa = balances_dec.locked();
252
253 let asset_coords = balances_dec.asset_decoder();
254 let asset_bytes = balances_dec.asset_slice(asset_coords);
255 let asset = Ustr::from(&String::from_utf8_lossy(asset_bytes));
256
257 balances.push(BinanceSpotBalanceEntry {
258 asset,
259 free: mantissa_to_decimal(free_mantissa, exponent),
260 locked: mantissa_to_decimal(locked_mantissa, exponent),
261 });
262 }
263
264 Ok(BinanceSpotAccountPositionMsg {
265 event_type: "outboundAccountPosition".to_string(),
266 event_time: us_to_ms(event_time_us),
267 last_update_time: us_to_ms(update_time_us),
268 balances,
269 })
270}
271
272pub fn decode_balance_update(data: &[u8]) -> anyhow::Result<BinanceSpotBalanceUpdateMsg> {
281 if data.len() < HEADER_LEN {
282 anyhow::bail!(
283 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
284 data.len()
285 );
286 }
287
288 let buf = ReadBuf::new(data);
289 let block_length = buf.get_u16_at(0);
290 let template_id = buf.get_u16_at(2);
291 let schema_id = buf.get_u16_at(4);
292 let version = buf.get_u16_at(6);
293
294 if template_id != balance_update_event_codec::SBE_TEMPLATE_ID {
295 anyhow::bail!(
296 "Wrong template ID: expected {}, received {template_id}",
297 balance_update_event_codec::SBE_TEMPLATE_ID
298 );
299 }
300
301 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
302 anyhow::bail!(
303 "Wrong schema ID: expected {}, received {schema_id}",
304 crate::spot::sbe::spot::SBE_SCHEMA_ID
305 );
306 }
307
308 let min_len = HEADER_LEN + block_length as usize;
309 if data.len() < min_len {
310 anyhow::bail!(
311 "Buffer too short for fixed block: expected {min_len}, was {}",
312 data.len()
313 );
314 }
315
316 let mut dec = balance_update_event_codec::BalanceUpdateEventDecoder::default().wrap(
317 buf,
318 HEADER_LEN,
319 block_length,
320 version,
321 );
322
323 let event_time_us = dec.event_time();
324 let clear_time_us = dec.clear_time().unwrap_or(0);
325 let qty_exponent = dec.qty_exponent();
326 let free_qty_delta = dec.free_qty_delta();
327
328 let asset = {
329 let coords = dec.asset_decoder();
330 String::from_utf8_lossy(dec.asset_slice(coords)).into_owned()
331 };
332
333 Ok(BinanceSpotBalanceUpdateMsg {
334 event_type: "balanceUpdate".to_string(),
335 event_time: us_to_ms(event_time_us),
336 asset: Ustr::from(&asset),
337 delta: mantissa_to_decimal_string(free_qty_delta, qty_exponent),
338 clear_time: us_to_ms(clear_time_us),
339 })
340}
341
342fn map_execution_type(
343 et: execution_type::ExecutionType,
344) -> anyhow::Result<BinanceSpotExecutionType> {
345 match et {
346 execution_type::ExecutionType::New => Ok(BinanceSpotExecutionType::New),
347 execution_type::ExecutionType::Canceled => Ok(BinanceSpotExecutionType::Canceled),
348 execution_type::ExecutionType::Replaced => Ok(BinanceSpotExecutionType::Replaced),
349 execution_type::ExecutionType::Rejected => Ok(BinanceSpotExecutionType::Rejected),
350 execution_type::ExecutionType::Trade => Ok(BinanceSpotExecutionType::Trade),
351 execution_type::ExecutionType::Expired => Ok(BinanceSpotExecutionType::Expired),
352 execution_type::ExecutionType::TradePrevention => {
353 Ok(BinanceSpotExecutionType::TradePrevention)
354 }
355 _ => anyhow::bail!("Unsupported SBE execution type: {et}"),
356 }
357}
358
359fn map_order_status(os: order_status::OrderStatus) -> BinanceOrderStatus {
360 match os {
361 order_status::OrderStatus::New => BinanceOrderStatus::New,
362 order_status::OrderStatus::PartiallyFilled => BinanceOrderStatus::PartiallyFilled,
363 order_status::OrderStatus::Filled => BinanceOrderStatus::Filled,
364 order_status::OrderStatus::Canceled => BinanceOrderStatus::Canceled,
365 order_status::OrderStatus::PendingCancel => BinanceOrderStatus::PendingCancel,
366 order_status::OrderStatus::Rejected => BinanceOrderStatus::Rejected,
367 order_status::OrderStatus::Expired => BinanceOrderStatus::Expired,
368 order_status::OrderStatus::ExpiredInMatch => BinanceOrderStatus::ExpiredInMatch,
369 _ => BinanceOrderStatus::Unknown,
370 }
371}
372
373fn map_side(side: order_side::OrderSide) -> anyhow::Result<BinanceSide> {
374 match side {
375 order_side::OrderSide::Buy => Ok(BinanceSide::Buy),
376 order_side::OrderSide::Sell => Ok(BinanceSide::Sell),
377 _ => anyhow::bail!("Unsupported SBE order side: {side}"),
378 }
379}
380
381fn map_time_in_force(tif: time_in_force::TimeInForce) -> BinanceTimeInForce {
382 match tif {
383 time_in_force::TimeInForce::Gtc => BinanceTimeInForce::Gtc,
384 time_in_force::TimeInForce::Ioc => BinanceTimeInForce::Ioc,
385 time_in_force::TimeInForce::Fok => BinanceTimeInForce::Fok,
386 _ => BinanceTimeInForce::Unknown,
387 }
388}
389
390fn map_order_type(ot: order_type::OrderType) -> &'static str {
391 match ot {
392 order_type::OrderType::Market => "MARKET",
393 order_type::OrderType::Limit => "LIMIT",
394 order_type::OrderType::StopLoss => "STOP_LOSS",
395 order_type::OrderType::StopLossLimit => "STOP_LOSS_LIMIT",
396 order_type::OrderType::TakeProfit => "TAKE_PROFIT",
397 order_type::OrderType::TakeProfitLimit => "TAKE_PROFIT_LIMIT",
398 order_type::OrderType::LimitMaker => "LIMIT_MAKER",
399 _ => "UNKNOWN",
400 }
401}
402
403fn map_expiry_reason(reason: expiry_reason::ExpiryReason) -> Option<String> {
404 match reason {
405 expiry_reason::ExpiryReason::Rejected => Some("REJECTED".to_string()),
406 expiry_reason::ExpiryReason::ExchangeCanceled => Some("EXCHANGE_CANCELED".to_string()),
407 expiry_reason::ExpiryReason::OcoTrigger => Some("OCO_TRIGGER".to_string()),
408 expiry_reason::ExpiryReason::OtoPhaseOneExpired => {
409 Some("OTO_PHASE_ONE_EXPIRED".to_string())
410 }
411 expiry_reason::ExpiryReason::UnfilledIocQuantityExpired => {
412 Some("UNFILLED_IOC_QUANTITY_EXPIRED".to_string())
413 }
414 expiry_reason::ExpiryReason::UnfilledFokOrderExpired => {
415 Some("UNFILLED_FOK_ORDER_EXPIRED".to_string())
416 }
417 expiry_reason::ExpiryReason::InsufficientLiquidity => {
418 Some("INSUFFICIENT_LIQUIDITY".to_string())
419 }
420 expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded => {
421 Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED".to_string())
422 }
423 expiry_reason::ExpiryReason::NonRepresentable => Some("NON_REPRESENTABLE".to_string()),
424 expiry_reason::ExpiryReason::NullVal => None,
425 }
426}
427
428#[inline]
430fn us_to_ms(us: i64) -> i64 {
431 us / 1000
432}
433
434fn mantissa_to_decimal(mantissa: i64, exponent: i8) -> Decimal {
436 if exponent >= 0 {
437 Decimal::from(mantissa) * Decimal::from(10_i64.pow(exponent as u32))
438 } else {
439 Decimal::new(mantissa, (-exponent) as u32)
440 }
441}
442
443fn mantissa_to_decimal_string(mantissa: i64, exponent: i8) -> String {
448 if mantissa == 0 {
449 if exponent >= 0 {
450 return "0".to_string();
451 }
452 let mut s = "0.".to_string();
453 for _ in 0..(-exponent) {
454 s.push('0');
455 }
456 return s;
457 }
458
459 let negative = mantissa < 0;
460 let abs_mantissa = mantissa.unsigned_abs();
461 let digits = abs_mantissa.to_string();
462
463 let result = if exponent >= 0 {
464 let mut s = digits;
465 for _ in 0..exponent {
466 s.push('0');
467 }
468 s
469 } else {
470 let decimal_places = (-exponent) as usize;
471 if digits.len() <= decimal_places {
472 let padding = decimal_places - digits.len();
473 let mut s = "0.".to_string();
474 for _ in 0..padding {
475 s.push('0');
476 }
477 s.push_str(&digits);
478 s
479 } else {
480 let split_pos = digits.len() - decimal_places;
481 let mut s = digits[..split_pos].to_string();
482 s.push('.');
483 s.push_str(&digits[split_pos..]);
484 s
485 }
486 };
487
488 if negative {
489 format!("-{result}")
490 } else {
491 result
492 }
493}
494
495#[cfg(test)]
496mod tests {
497 use rstest::rstest;
498
499 use super::*;
500 use crate::spot::sbe::spot::{
501 WriteBuf, bool_enum::BoolEnum, execution_type::ExecutionType, floor, match_type,
502 order_capacity, order_side::OrderSide, order_status::OrderStatus,
503 order_type::OrderType as SbeOrderType, peg_offset_type, peg_price_type,
504 self_trade_prevention_mode::SelfTradePreventionMode, time_in_force::TimeInForce as SbeTif,
505 };
506
507 #[expect(clippy::too_many_arguments)]
508 fn encode_execution_report(
509 symbol: &str,
510 client_order_id: &str,
511 order_id: i64,
512 trade_id: Option<i64>,
513 side: OrderSide,
514 order_type: SbeOrderType,
515 tif: SbeTif,
516 exec_type: ExecutionType,
517 status: OrderStatus,
518 price_exp: i8,
519 qty_exp: i8,
520 commission_exp: i8,
521 price_mantissa: i64,
522 orig_qty_mantissa: i64,
523 stop_price_mantissa: i64,
524 last_qty_mantissa: i64,
525 last_price_mantissa: i64,
526 executed_qty_mantissa: i64,
527 cumm_quote_qty_mantissa: i64,
528 commission_mantissa: i64,
529 commission_asset: &str,
530 is_maker: bool,
531 is_working: bool,
532 event_time_us: i64,
533 transact_time_us: i64,
534 order_creation_time_us: Option<i64>,
535 expiry_reason: expiry_reason::ExpiryReason,
536 ) -> Vec<u8> {
537 let var_data_len = 6 + symbol.len() + client_order_id.len() + commission_asset.len();
538 let total = 8 + execution_report_event_codec::SBE_BLOCK_LENGTH as usize + var_data_len;
539 let mut buf_vec = vec![0u8; total];
540
541 let buf = WriteBuf::new(buf_vec.as_mut_slice());
542 let enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
543 .wrap(buf, HEADER_LEN);
544 let mut header = enc.header(0);
545 let mut enc = header.parent().unwrap();
546
547 enc.event_time(event_time_us);
548 enc.transact_time(transact_time_us);
549 enc.price_exponent(price_exp);
550 enc.qty_exponent(qty_exp);
551 enc.commission_exponent(commission_exp);
552 enc.order_creation_time(order_creation_time_us.unwrap_or(i64::MIN));
553 enc.working_time(i64::MIN); enc.order_id(order_id);
555 enc.order_list_id(i64::MIN); enc.orig_qty(orig_qty_mantissa);
557 enc.price(price_mantissa);
558 enc.orig_quote_order_qty(0);
559 enc.iceberg_qty(0);
560 enc.stop_price(stop_price_mantissa);
561 enc.order_type(order_type);
562 enc.side(side);
563 enc.time_in_force(tif);
564 enc.execution_type(exec_type);
565 enc.order_status(status);
566 enc.trade_id(trade_id.unwrap_or(i64::MIN));
567 enc.execution_id(0);
568 enc.executed_qty(executed_qty_mantissa);
569 enc.cummulative_quote_qty(cumm_quote_qty_mantissa);
570 enc.last_qty(last_qty_mantissa);
571 enc.last_price(last_price_mantissa);
572 enc.quote_qty(0);
573 enc.commission(commission_mantissa);
574 enc.is_working(if is_working {
575 BoolEnum::True
576 } else {
577 BoolEnum::False
578 });
579 enc.is_maker(if is_maker {
580 BoolEnum::True
581 } else {
582 BoolEnum::False
583 });
584 enc.is_best_match(BoolEnum::False);
585 enc.match_type(match_type::MatchType::default());
586 enc.self_trade_prevention_mode(SelfTradePreventionMode::default());
587 enc.order_capacity(order_capacity::OrderCapacity::default());
588 enc.working_floor(floor::Floor::default());
589 enc.used_sor(BoolEnum::False);
590 enc.alloc_id(i64::MIN);
591 enc.trailing_delta(u64::MAX);
592 enc.trailing_time(i64::MIN);
593 enc.trade_group_id(i64::MIN);
594 enc.prevented_qty(0);
595 enc.last_prevented_qty(i64::MIN);
596 enc.prevented_match_id(i64::MIN);
597 enc.prevented_execution_qty(i64::MIN);
598 enc.prevented_execution_price(i64::MIN);
599 enc.prevented_execution_quote_qty(i64::MIN);
600 enc.strategy_type(i32::MIN);
601 enc.strategy_id(i64::MIN);
602 enc.counter_order_id(i64::MIN);
603 enc.subscription_id(0xFFFF); enc.peg_price_type(peg_price_type::PegPriceType::default());
605 enc.peg_offset_type(peg_offset_type::PegOffsetType::default());
606 enc.peg_offset_value(0xFF); enc.pegged_price(i64::MIN);
608 enc.expiry_reason(expiry_reason);
609
610 enc.symbol(symbol);
612 enc.client_order_id(client_order_id);
613 enc.orig_client_order_id("");
614 enc.commission_asset(commission_asset);
615 enc.reject_reason("");
616 enc.counter_symbol("");
617
618 buf_vec
619 }
620
621 fn encode_account_position(
622 event_time_us: i64,
623 update_time_us: i64,
624 balances: &[(&str, i8, i64, i64)], ) -> Vec<u8> {
626 let var_data_len: usize = balances.iter().map(|(a, _, _, _)| 1 + a.len()).sum();
627 let total = 8 + 18 + 6 + (balances.len() * 17) + var_data_len;
628 let mut buf_vec = vec![0u8; total];
629
630 let buf = WriteBuf::new(buf_vec.as_mut_slice());
631 let enc =
632 outbound_account_position_event_codec::OutboundAccountPositionEventEncoder::default()
633 .wrap(buf, HEADER_LEN);
634 let mut header = enc.header(0);
635 let mut enc = header.parent().unwrap();
636
637 enc.event_time(event_time_us);
638 enc.update_time(update_time_us);
639 enc.subscription_id(0xFFFF); let balances_enc =
642 outbound_account_position_event_codec::encoder::BalancesEncoder::default();
643 let mut bal_enc = enc.balances_encoder(balances.len() as u32, balances_enc);
644
645 for (asset, exponent, free, locked) in balances {
646 bal_enc.advance().unwrap();
647 bal_enc.exponent(*exponent);
648 bal_enc.free(*free);
649 bal_enc.locked(*locked);
650 bal_enc.asset(asset);
651 }
652
653 buf_vec
654 }
655
656 #[rstest]
657 fn test_mantissa_to_decimal_string_basic() {
658 assert_eq!(mantissa_to_decimal_string(250000, -2), "2500.00");
659 assert_eq!(mantissa_to_decimal_string(100000000, -8), "1.00000000");
660 assert_eq!(mantissa_to_decimal_string(0, -8), "0.00000000");
661 assert_eq!(mantissa_to_decimal_string(0, 0), "0");
662 assert_eq!(mantissa_to_decimal_string(42, 0), "42");
663 assert_eq!(mantissa_to_decimal_string(42, 2), "4200");
664 assert_eq!(mantissa_to_decimal_string(5, -3), "0.005");
665 assert_eq!(mantissa_to_decimal_string(-250000, -2), "-2500.00");
666 }
667
668 #[rstest]
669 #[case::typical_price(250000_i64, -2_i8, "2500.00")]
670 #[case::btc_one(100000000_i64, -8_i8, "1.00000000")]
671 #[case::zero_negative_exp(0_i64, -8_i8, "0")]
672 #[case::zero_zero_exp(0_i64, 0_i8, "0")]
673 #[case::whole_no_scale(42_i64, 0_i8, "42")]
674 #[case::positive_exponent(42_i64, 2_i8, "4200")]
675 #[case::small_fractional(5_i64, -3_i8, "0.005")]
676 #[case::negative_mantissa(-250000_i64, -2_i8, "-2500.00")]
677 #[case::large_positive_exponent(1_i64, 9_i8, "1000000000")]
678 fn test_mantissa_to_decimal_parametrized(
679 #[case] mantissa: i64,
680 #[case] exponent: i8,
681 #[case] expected: &str,
682 ) {
683 let result = mantissa_to_decimal(mantissa, exponent);
684 assert_eq!(result, Decimal::from_str_exact(expected).unwrap());
685 }
686
687 #[rstest]
688 #[case::rejected(expiry_reason::ExpiryReason::Rejected, Some("REJECTED"))]
689 #[case::exchange_canceled(
690 expiry_reason::ExpiryReason::ExchangeCanceled,
691 Some("EXCHANGE_CANCELED")
692 )]
693 #[case::oco_trigger(expiry_reason::ExpiryReason::OcoTrigger, Some("OCO_TRIGGER"))]
694 #[case::oto_phase_one_expired(
695 expiry_reason::ExpiryReason::OtoPhaseOneExpired,
696 Some("OTO_PHASE_ONE_EXPIRED")
697 )]
698 #[case::unfilled_ioc_quantity_expired(
699 expiry_reason::ExpiryReason::UnfilledIocQuantityExpired,
700 Some("UNFILLED_IOC_QUANTITY_EXPIRED")
701 )]
702 #[case::unfilled_fok_order_expired(
703 expiry_reason::ExpiryReason::UnfilledFokOrderExpired,
704 Some("UNFILLED_FOK_ORDER_EXPIRED")
705 )]
706 #[case::insufficient_liquidity(
707 expiry_reason::ExpiryReason::InsufficientLiquidity,
708 Some("INSUFFICIENT_LIQUIDITY")
709 )]
710 #[case::execution_rule_price_range_exceeded(
711 expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded,
712 Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED")
713 )]
714 #[case::non_representable(
715 expiry_reason::ExpiryReason::NonRepresentable,
716 Some("NON_REPRESENTABLE")
717 )]
718 #[case::null_val(expiry_reason::ExpiryReason::NullVal, None)]
719 fn test_map_expiry_reason(
720 #[case] reason: expiry_reason::ExpiryReason,
721 #[case] expected: Option<&str>,
722 ) {
723 let result = map_expiry_reason(reason);
724
725 assert_eq!(result.as_deref(), expected);
726 }
727
728 #[rstest]
729 fn test_decode_execution_report_new_limit() {
730 let data = encode_execution_report(
731 "ETHUSDT",
732 "O-20200101-000000-000-000-0",
733 12345678,
734 None, OrderSide::Buy,
736 SbeOrderType::Limit,
737 SbeTif::Gtc,
738 ExecutionType::New,
739 OrderStatus::New,
740 -2, -5, -8, 250000, 100000, 0, 0, 0, 0, 0, 0, "", false,
753 true, 1709654400000000, 1709654400000000, Some(1709654400000000),
757 expiry_reason::ExpiryReason::NullVal,
758 );
759
760 let report = decode_execution_report(&data).unwrap();
761
762 assert_eq!(report.symbol, "ETHUSDT");
763 assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
764 assert_eq!(report.order_id, 12345678);
765 assert_eq!(report.side, BinanceSide::Buy);
766 assert_eq!(report.order_type, "LIMIT");
767 assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
768 assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
769 assert_eq!(report.order_status, BinanceOrderStatus::New);
770 assert_eq!(report.price, "2500.00");
771 assert_eq!(report.original_qty, "1.00000");
772 assert_eq!(report.trade_id, -1);
773 assert!(report.is_working);
774 assert!(!report.is_maker);
775 assert_eq!(report.event_time, 1709654400000);
776 assert_eq!(report.transaction_time, 1709654400000);
777 assert!(report.expiry_reason.is_none());
778 }
779
780 #[rstest]
781 fn test_decode_execution_report_expiry_reason() {
782 let data = encode_execution_report(
783 "ETHUSDT",
784 "O-20200101-000000-000-000-0",
785 12345678,
786 None,
787 OrderSide::Buy,
788 SbeOrderType::Limit,
789 SbeTif::Ioc,
790 ExecutionType::Expired,
791 OrderStatus::Expired,
792 -2,
793 -5,
794 -8,
795 250000,
796 100000,
797 0,
798 0,
799 0,
800 0,
801 0,
802 0,
803 "",
804 false,
805 false,
806 1709654400000000,
807 1709654400000000,
808 Some(1709654400000000),
809 expiry_reason::ExpiryReason::InsufficientLiquidity,
810 );
811
812 let report = decode_execution_report(&data).unwrap();
813
814 assert_eq!(report.execution_type, BinanceSpotExecutionType::Expired);
815 assert_eq!(report.order_status, BinanceOrderStatus::Expired);
816 assert_eq!(
817 report.expiry_reason.as_deref(),
818 Some("INSUFFICIENT_LIQUIDITY")
819 );
820 }
821
822 #[rstest]
823 fn test_decode_execution_report_trade_fill() {
824 let data = encode_execution_report(
825 "ETHUSDT",
826 "O-20200101-000000-000-000-0",
827 12345678,
828 Some(98765432),
829 OrderSide::Buy,
830 SbeOrderType::Limit,
831 SbeTif::Gtc,
832 ExecutionType::Trade,
833 OrderStatus::Filled,
834 -2, -8, -8, 250000, 100000000, 0, 100000000, 250000, 100000000, 250000000000, 250000, "USDT",
846 true, false,
848 1709654400000000,
849 1709654400000000,
850 Some(1709654400000000),
851 expiry_reason::ExpiryReason::NullVal,
852 );
853
854 let report = decode_execution_report(&data).unwrap();
855
856 assert_eq!(report.execution_type, BinanceSpotExecutionType::Trade);
857 assert_eq!(report.order_status, BinanceOrderStatus::Filled);
858 assert_eq!(report.trade_id, 98765432);
859 assert_eq!(report.last_filled_qty, "1.00000000");
860 assert_eq!(report.last_filled_price, "2500.00");
861 assert_eq!(report.commission_asset, Some(Ustr::from("USDT")));
862 assert!(report.is_maker);
863 }
864
865 #[rstest]
866 fn test_decode_execution_report_canceled() {
867 let data = encode_execution_report(
868 "BTCUSDT",
869 "O-20200101-000000-000-000-1",
870 99999,
871 None,
872 OrderSide::Sell,
873 SbeOrderType::Limit,
874 SbeTif::Gtc,
875 ExecutionType::Canceled,
876 OrderStatus::Canceled,
877 -2,
878 -8,
879 -8,
880 5000000, 10000000, 0,
883 0,
884 0,
885 0,
886 0,
887 0,
888 "",
889 false,
890 false,
891 1709654400000000,
892 1709654400000000,
893 Some(1709654400000000),
894 expiry_reason::ExpiryReason::NullVal,
895 );
896
897 let report = decode_execution_report(&data).unwrap();
898
899 assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
900 assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
901 assert_eq!(report.symbol, "BTCUSDT");
902 assert_eq!(report.side, BinanceSide::Sell);
903 }
904
905 #[rstest]
906 fn test_decode_execution_report_stop_loss_limit() {
907 let data = encode_execution_report(
908 "ETHUSDT",
909 "O-20200101-000000-000-000-1",
910 12345679,
911 None,
912 OrderSide::Sell,
913 SbeOrderType::StopLossLimit,
914 SbeTif::Gtc,
915 ExecutionType::New,
916 OrderStatus::New,
917 -2,
918 -5,
919 -8,
920 240000, 100000, 245000, 0,
924 0,
925 0,
926 0,
927 0,
928 "",
929 false,
930 true,
931 1709654400000000,
932 1709654400000000,
933 Some(1709654400000000),
934 expiry_reason::ExpiryReason::NullVal,
935 );
936
937 let report = decode_execution_report(&data).unwrap();
938
939 assert_eq!(report.order_type, "STOP_LOSS_LIMIT");
940 assert_eq!(report.price, "2400.00");
941 assert_eq!(report.stop_price, "2450.00");
942 }
943
944 #[rstest]
945 fn test_decode_execution_report_truncated_header() {
946 let data = vec![0u8; 5];
947 let err = decode_execution_report(&data).unwrap_err();
948 assert!(err.to_string().contains("too short for SBE header"));
949 }
950
951 #[rstest]
952 fn test_decode_execution_report_wrong_template() {
953 let mut data = encode_execution_report(
954 "TEST",
955 "test",
956 1,
957 None,
958 OrderSide::Buy,
959 SbeOrderType::Limit,
960 SbeTif::Gtc,
961 ExecutionType::New,
962 OrderStatus::New,
963 -2,
964 -8,
965 -8,
966 0,
967 0,
968 0,
969 0,
970 0,
971 0,
972 0,
973 0,
974 "",
975 false,
976 false,
977 0,
978 0,
979 None,
980 expiry_reason::ExpiryReason::NullVal,
981 );
982 data[2..4].copy_from_slice(&50u16.to_le_bytes());
984
985 let err = decode_execution_report(&data).unwrap_err();
986 assert!(err.to_string().contains("Wrong template ID"));
987 }
988
989 #[rstest]
990 fn test_decode_account_position_single_balance() {
991 let data = encode_account_position(
992 1709654400000000, 1709654400000000, &[("USDT", -8, 1000000000000, 50000000000)], );
996
997 let msg = decode_account_position(&data).unwrap();
998
999 assert_eq!(msg.event_type, "outboundAccountPosition");
1000 assert_eq!(msg.event_time, 1709654400000);
1001 assert_eq!(msg.balances.len(), 1);
1002 assert_eq!(msg.balances[0].asset, "USDT");
1003 assert_eq!(
1004 msg.balances[0].free,
1005 Decimal::from_str_exact("10000.00000000").unwrap()
1006 );
1007 assert_eq!(
1008 msg.balances[0].locked,
1009 Decimal::from_str_exact("500.00000000").unwrap()
1010 );
1011 }
1012
1013 #[rstest]
1014 fn test_decode_account_position_multiple_balances() {
1015 let data = encode_account_position(
1016 1709654400000000,
1017 1709654400000000,
1018 &[
1019 ("BTC", -8, 100000000, 0), ("USDT", -8, 5000000000000, 0), ],
1022 );
1023
1024 let msg = decode_account_position(&data).unwrap();
1025
1026 assert_eq!(msg.balances.len(), 2);
1027 assert_eq!(msg.balances[0].asset, "BTC");
1028 assert_eq!(
1029 msg.balances[0].free,
1030 Decimal::from_str_exact("1.00000000").unwrap()
1031 );
1032 assert_eq!(msg.balances[1].asset, "USDT");
1033 assert_eq!(
1034 msg.balances[1].free,
1035 Decimal::from_str_exact("50000.00000000").unwrap()
1036 );
1037 }
1038
1039 #[rstest]
1040 fn test_decode_account_position_zero_balances() {
1041 let data = encode_account_position(1709654400000000, 1709654400000000, &[]);
1042
1043 let msg = decode_account_position(&data).unwrap();
1044 assert!(msg.balances.is_empty());
1045 }
1046
1047 #[rstest]
1048 fn test_decode_account_position_truncated_header() {
1049 let data = vec![0u8; 5];
1050 let err = decode_account_position(&data).unwrap_err();
1051 assert!(err.to_string().contains("too short for SBE header"));
1052 }
1053
1054 #[rstest]
1055 fn test_decode_account_position_wrong_template() {
1056 let mut data = encode_account_position(0, 0, &[]);
1057 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1058
1059 let err = decode_account_position(&data).unwrap_err();
1060 assert!(err.to_string().contains("Wrong template ID"));
1061 }
1062
1063 fn encode_balance_update(
1064 event_time_us: i64,
1065 clear_time_us: i64,
1066 qty_exponent: i8,
1067 free_qty_delta: i64,
1068 asset: &str,
1069 ) -> Vec<u8> {
1070 let total = 8 + 27 + 1 + asset.len();
1071 let mut buf_vec = vec![0u8; total];
1072
1073 let buf = WriteBuf::new(buf_vec.as_mut_slice());
1074 let enc =
1075 balance_update_event_codec::BalanceUpdateEventEncoder::default().wrap(buf, HEADER_LEN);
1076 let mut header = enc.header(0);
1077 let mut enc = header.parent().unwrap();
1078
1079 enc.event_time(event_time_us);
1080 enc.clear_time(clear_time_us);
1081 enc.qty_exponent(qty_exponent);
1082 enc.free_qty_delta(free_qty_delta);
1083 enc.subscription_id(0xFFFF); enc.asset(asset);
1085
1086 buf_vec
1087 }
1088
1089 #[rstest]
1090 fn test_decode_balance_update() {
1091 let data = encode_balance_update(
1092 1709654400000000, 1709654400000000, -8,
1095 10000000000, "BTC",
1097 );
1098
1099 let msg = decode_balance_update(&data).unwrap();
1100
1101 assert_eq!(msg.event_type, "balanceUpdate");
1102 assert_eq!(msg.event_time, 1709654400000);
1103 assert_eq!(msg.asset, "BTC");
1104 assert_eq!(msg.delta, "100.00000000");
1105 assert_eq!(msg.clear_time, 1709654400000);
1106 }
1107
1108 #[rstest]
1109 fn test_decode_balance_update_truncated_header() {
1110 let data = vec![0u8; 5];
1111 let err = decode_balance_update(&data).unwrap_err();
1112 assert!(err.to_string().contains("too short for SBE header"));
1113 }
1114
1115 #[rstest]
1116 fn test_decode_balance_update_wrong_template() {
1117 let mut data = encode_balance_update(0, 0, -8, 0, "BTC");
1118 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1119
1120 let err = decode_balance_update(&data).unwrap_err();
1121 assert!(err.to_string().contains("Wrong template ID"));
1122 }
1123
1124 #[rstest]
1125 fn test_us_to_ms() {
1126 assert_eq!(us_to_ms(1709654400000000), 1709654400000);
1127 assert_eq!(us_to_ms(1709654400123456), 1709654400123);
1128 }
1129
1130 #[rstest]
1131 fn test_decode_captured_execution_report_new() {
1132 let data = crate::common::testing::load_fixture_bytes(
1133 "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1134 );
1135 let report = decode_execution_report(&data).unwrap();
1136
1137 assert_eq!(report.symbol, "BTCUSDT");
1138 assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
1139 assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
1140 assert_eq!(report.order_status, BinanceOrderStatus::New);
1141 assert_eq!(report.side, BinanceSide::Buy);
1142 assert_eq!(report.order_type, "LIMIT");
1143 assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
1144 assert_eq!(report.order_id, 12345678);
1145 assert!(report.is_working);
1146 assert!(!report.is_maker);
1147 assert_eq!(report.trade_id, -1);
1148 }
1149
1150 #[rstest]
1151 fn test_decode_captured_execution_report_canceled() {
1152 let data = crate::common::testing::load_fixture_bytes(
1153 "spot/user_data_sbe/mainnet/execution_report_event_2.sbe",
1154 );
1155 let report = decode_execution_report(&data).unwrap();
1156
1157 assert_eq!(report.symbol, "BTCUSDT");
1158 assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1159 assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1160 assert_eq!(report.order_id, 12345678);
1161 assert!(!report.is_working);
1162 }
1163
1164 #[rstest]
1165 fn test_decode_captured_account_position() {
1166 let data = crate::common::testing::load_fixture_bytes(
1167 "spot/user_data_sbe/mainnet/outbound_account_position_event_1.sbe",
1168 );
1169 let msg = decode_account_position(&data).unwrap();
1170
1171 assert_eq!(msg.event_type, "outboundAccountPosition");
1172 assert_eq!(msg.balances.len(), 3);
1173 assert_eq!(msg.balances[0].asset, "BTC");
1174 assert_eq!(
1175 msg.balances[0].free,
1176 Decimal::from_str_exact("1.00000000").unwrap()
1177 );
1178 assert_eq!(msg.balances[1].asset, "BNB");
1179 assert_eq!(msg.balances[2].asset, "USDT");
1180 assert_eq!(
1181 msg.balances[2].free,
1182 Decimal::from_str_exact("50000.00000000").unwrap()
1183 );
1184 }
1185}