1use ahash::AHashSet;
24use anyhow::Context;
25use nautilus_serialization::sbe::{GroupSize16Encoding, GroupSizeEncoding, SbeCursor};
26use ustr::Ustr;
27
28use super::user_data::{
29 BinanceSpotAccountPositionMsg, BinanceSpotBalanceEntry, BinanceSpotBalanceUpdateMsg,
30 BinanceSpotExecutionReport, BinanceSpotExecutionType, BinanceSpotListOrder,
31 BinanceSpotListStatus,
32};
33use crate::{
34 common::{
35 consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
36 encoder::decode_client_order_id,
37 enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
38 parse::{parse_micros, sbe_mantissa_to_decimal},
39 },
40 spot::sbe::spot::{
41 ReadBuf, balance_update_event_codec, bool_enum, contingency_type,
42 execution_report_event_codec, execution_type, expiry_reason, group_size_encoding_codec,
43 list_order_status, list_status_event_codec, list_status_type, message_header_codec,
44 order_side, order_status, order_type, outbound_account_position_event_codec, time_in_force,
45 },
46};
47
48const HEADER_LEN: usize = message_header_codec::ENCODED_LENGTH;
49const ACCOUNT_POSITION_BLOCK_LENGTH_V0: usize = 16;
50const ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH: usize = 17;
51const BALANCE_UPDATE_BLOCK_LENGTH_V0: usize = 25;
52
53const EXECUTION_REPORT_BLOCK_LENGTH_V0: usize = 268;
55const EXECUTION_REPORT_BLOCK_LENGTH_V1: usize = 281;
56const EXECUTION_REPORT_BLOCK_LENGTH_V3: usize = 282;
57const EXECUTION_REPORT_VAR_DATA_FIELDS: [&str; 6] = [
58 "symbol",
59 "client_order_id",
60 "orig_client_order_id",
61 "commission_asset",
62 "reject_reason",
63 "counter_symbol",
64];
65
66pub fn decode_execution_report(data: &[u8]) -> anyhow::Result<BinanceSpotExecutionReport> {
80 if data.len() < HEADER_LEN {
81 anyhow::bail!(
82 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
83 data.len()
84 );
85 }
86
87 let buf = ReadBuf::new(data);
88 let block_length = buf.get_u16_at(0);
89 let template_id = buf.get_u16_at(2);
90 let schema_id = buf.get_u16_at(4);
91 let version = buf.get_u16_at(6);
92
93 if template_id != execution_report_event_codec::SBE_TEMPLATE_ID {
94 anyhow::bail!(
95 "Wrong template ID: expected {}, received {template_id}",
96 execution_report_event_codec::SBE_TEMPLATE_ID
97 );
98 }
99
100 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
101 anyhow::bail!(
102 "Wrong schema ID: expected {}, received {schema_id}",
103 crate::spot::sbe::spot::SBE_SCHEMA_ID
104 );
105 }
106
107 let min_block_len = execution_report_min_block_length(version);
108 if usize::from(block_length) < min_block_len {
109 anyhow::bail!(
110 "SBE execution report block length too short: expected at least {min_block_len}, was {block_length}"
111 );
112 }
113
114 let min_len = HEADER_LEN + usize::from(block_length);
115 if data.len() < min_len {
116 anyhow::bail!(
117 "Buffer too short for fixed block: expected {min_len}, was {}",
118 data.len()
119 );
120 }
121
122 let mut field_offset = min_len;
123 for field in EXECUTION_REPORT_VAR_DATA_FIELDS {
124 let Some(length) = data.get(field_offset) else {
125 anyhow::bail!(
126 "Buffer too short for {field} length: expected {}, was {}",
127 field_offset + 1,
128 data.len()
129 );
130 };
131 let expected_len = field_offset + 1 + usize::from(*length);
132 if data.len() < expected_len {
133 anyhow::bail!(
134 "Buffer too short for {field}: expected {expected_len}, was {}",
135 data.len()
136 );
137 }
138 field_offset = expected_len;
139 }
140
141 let mut dec = execution_report_event_codec::ExecutionReportEventDecoder::default().wrap(
142 buf,
143 HEADER_LEN,
144 block_length,
145 version,
146 );
147
148 let price_exp = dec.price_exponent();
149 let qty_exp = dec.qty_exponent();
150 let commission_exp = dec.commission_exponent();
151
152 let event_time_us = dec.event_time();
153 let transact_time_us = dec.transact_time();
154 let order_creation_time_us = dec.order_creation_time();
155 let order_id = dec.order_id();
156 let trade_id = dec.trade_id().unwrap_or(-1);
157
158 let execution_type = map_execution_type(dec.execution_type())?;
159 let order_status = map_order_status(dec.order_status());
160 let side = map_side(dec.side())?;
161 let time_in_force = map_time_in_force(dec.time_in_force());
162 let order_type_str = map_order_type(dec.order_type());
163 let expiry_reason = map_expiry_reason(dec.expiry_reason());
164 let is_working = dec.is_working() == bool_enum::BoolEnum::True;
165 let is_maker = dec.is_maker() == bool_enum::BoolEnum::True;
166
167 let price_mantissa = dec.price();
168 let orig_qty_mantissa = dec.orig_qty();
169 let stop_price_mantissa = dec.stop_price();
170 let last_qty_mantissa = dec.last_qty();
171 let last_price_mantissa = dec.last_price();
172 let executed_qty_mantissa = dec.executed_qty();
173 let cummulative_quote_qty_mantissa = dec.cummulative_quote_qty();
174 let commission_mantissa = dec.commission();
175
176 let symbol = {
179 let coords = dec.symbol_decoder();
180 String::from_utf8_lossy(dec.symbol_slice(coords)).into_owned()
181 };
182
183 let client_order_id = {
184 let coords = dec.client_order_id_decoder();
185 String::from_utf8_lossy(dec.client_order_id_slice(coords)).into_owned()
186 };
187
188 let orig_client_order_id = {
189 let coords = dec.orig_client_order_id_decoder();
190 let s = String::from_utf8_lossy(dec.orig_client_order_id_slice(coords)).into_owned();
191 if s.is_empty() { None } else { Some(s) }
192 };
193
194 let commission_asset = {
195 let coords = dec.commission_asset_decoder();
196 let bytes = dec.commission_asset_slice(coords);
197 if bytes.is_empty() {
198 None
199 } else {
200 Some(Ustr::from(&String::from_utf8_lossy(bytes)))
201 }
202 };
203
204 let reject_reason = {
205 let coords = dec.reject_reason_decoder();
206 String::from_utf8_lossy(dec.reject_reason_slice(coords)).into_owned()
207 };
208
209 let _counter_symbol_coords = dec.counter_symbol_decoder();
211
212 Ok(BinanceSpotExecutionReport {
213 event_type: "executionReport".to_string(),
214 event_time: us_to_ms(event_time_us),
215 symbol: Ustr::from(&symbol),
216 client_order_id,
217 side,
218 order_type: order_type_str.to_string(),
219 time_in_force,
220 original_qty: mantissa_to_decimal_string(orig_qty_mantissa, qty_exp)?,
221 price: mantissa_to_decimal_string(price_mantissa, price_exp)?,
222 stop_price: mantissa_to_decimal_string(stop_price_mantissa, price_exp)?,
223 execution_type,
224 order_status,
225 reject_reason,
226 order_id,
227 last_filled_qty: mantissa_to_decimal_string(last_qty_mantissa, qty_exp)?,
228 cumulative_filled_qty: mantissa_to_decimal_string(executed_qty_mantissa, qty_exp)?,
229 last_filled_price: mantissa_to_decimal_string(last_price_mantissa, price_exp)?,
230 commission: mantissa_to_decimal_string(commission_mantissa, commission_exp)?,
231 commission_asset,
232 transaction_time: us_to_ms(transact_time_us),
233 trade_id,
234 is_working,
235 is_maker,
236 order_creation_time: order_creation_time_us.map_or(0, us_to_ms),
237 cumulative_quote_qty: mantissa_to_decimal_string(
238 cummulative_quote_qty_mantissa,
239 price_exp,
240 )?,
241 original_client_order_id: orig_client_order_id,
242 expiry_reason,
243 })
244}
245
246pub fn decode_list_status(data: &[u8]) -> anyhow::Result<BinanceSpotListStatus> {
256 let mut cursor = SbeCursor::new(data);
257 let block_length = cursor.read_u16_le()?;
258 let template_id = cursor.read_u16_le()?;
259 let schema_id = cursor.read_u16_le()?;
260 let version = cursor.read_u16_le()?;
261 anyhow::ensure!(
262 template_id == list_status_event_codec::SBE_TEMPLATE_ID,
263 "Wrong list status template ID: {template_id}"
264 );
265 anyhow::ensure!(
266 schema_id == crate::spot::sbe::spot::SBE_SCHEMA_ID,
267 "Wrong list status schema ID: {schema_id}"
268 );
269 anyhow::ensure!(
270 version <= crate::spot::sbe::spot::SBE_SCHEMA_VERSION,
271 "Unsupported list status schema version: {version}"
272 );
273
274 let minimum = if version == 0 {
275 27
276 } else {
277 usize::from(list_status_event_codec::SBE_BLOCK_LENGTH)
278 };
279
280 anyhow::ensure!(
281 usize::from(block_length) >= minimum,
282 "List status fixed block is too short: {block_length}"
283 );
284 cursor.advance(usize::from(block_length))?;
285 let group =
286 GroupSize16Encoding::decode(cursor.read_bytes(GroupSize16Encoding::ENCODED_LENGTH)?)?;
287 anyhow::ensure!(group.block_length >= 8, "List child block is too short");
288 anyhow::ensure!(group.num_in_group > 0, "List has no children");
289
290 for _ in 0..group.num_in_group {
291 cursor.advance(usize::from(group.block_length))?;
292 cursor.read_var_string8_ref()?;
293 cursor.read_var_string8_ref()?;
294 }
295
296 for _ in 0..3 {
297 cursor.read_var_string8_ref()?;
298 }
299
300 anyhow::ensure!(cursor.remaining() == 0, "Trailing data after list status");
301
302 let decoder = list_status_event_codec::ListStatusEventDecoder::default().wrap(
303 ReadBuf::new(data),
304 HEADER_LEN,
305 block_length,
306 version,
307 );
308 let event_time_us = decoder.event_time();
309 let transaction_time_us = decoder.transact_time();
310 parse_micros(event_time_us, "list event time")?;
311 parse_micros(transaction_time_us, "list transaction time")?;
312 let order_list_id = decoder.order_list_id();
313 anyhow::ensure!(order_list_id >= 0, "Invalid order list ID: {order_list_id}");
314 let contingency_type = decoder.contingency_type();
315 let list_status_type = decoder.list_status_type();
316 let list_order_status = decoder.list_order_status();
317 anyhow::ensure!(
318 matches!(
319 contingency_type,
320 contingency_type::ContingencyType::Oco | contingency_type::ContingencyType::Oto
321 ),
322 "Unsupported list contingency type"
323 );
324 anyhow::ensure!(
325 matches!(
326 list_status_type,
327 list_status_type::ListStatusType::Response
328 | list_status_type::ListStatusType::ExecStarted
329 | list_status_type::ListStatusType::AllDone
330 | list_status_type::ListStatusType::Updated
331 ),
332 "Unsupported list status type"
333 );
334 anyhow::ensure!(
335 matches!(
336 list_order_status,
337 list_order_status::ListOrderStatus::Canceling
338 | list_order_status::ListOrderStatus::Executing
339 | list_order_status::ListOrderStatus::AllDone
340 | list_order_status::ListOrderStatus::Reject
341 ),
342 "Unsupported list order status"
343 );
344 let subscription_id = decoder.subscription_id();
345 let mut children = decoder.orders_decoder();
346 let mut orders = Vec::with_capacity(usize::from(group.num_in_group));
347 let mut venue_ids = AHashSet::new();
348 let mut client_ids = AHashSet::new();
349
350 while children
351 .advance()
352 .map_err(|e| anyhow::anyhow!("Invalid list child group: {e:?}"))?
353 .is_some()
354 {
355 let order_id = children.order_id();
356 anyhow::ensure!(
357 order_id >= 0 && venue_ids.insert(order_id),
358 "Invalid or duplicate list child order ID: {order_id}"
359 );
360 let coords = children.symbol_decoder();
361 let symbol = std::str::from_utf8(children.symbol_slice(coords))?;
362 validate_list_identifier(symbol)?;
363 let symbol = Ustr::from(symbol);
364 let coords = children.client_order_id_decoder();
365 let client_order_id = std::str::from_utf8(children.client_order_id_slice(coords))?;
366 validate_list_identifier(client_order_id)?;
367 let decoded_id = decode_client_order_id(client_order_id, BINANCE_NAUTILUS_SPOT_BROKER_ID)?;
368 anyhow::ensure!(
369 client_ids.insert(decoded_id),
370 "Duplicate list child client order ID"
371 );
372 orders.push(BinanceSpotListOrder {
373 order_id,
374 symbol,
375 client_order_id: client_order_id.to_owned(),
376 });
377 }
378
379 let mut decoder = children
380 .parent()
381 .map_err(|e| anyhow::anyhow!("Invalid list parent: {e:?}"))?;
382 let coords = decoder.symbol_decoder();
383 let symbol = std::str::from_utf8(decoder.symbol_slice(coords))?;
384 validate_list_identifier(symbol)?;
385 let symbol = Ustr::from(symbol);
386 anyhow::ensure!(
387 orders.iter().all(|order| order.symbol == symbol),
388 "List child symbol does not match list symbol"
389 );
390 let coords = decoder.list_client_order_id_decoder();
391 let list_client_order_id =
392 std::str::from_utf8(decoder.list_client_order_id_slice(coords))?.to_owned();
393 validate_list_identifier(&list_client_order_id)?;
394 let coords = decoder.reject_reason_decoder();
395 let reject_reason = std::str::from_utf8(decoder.reject_reason_slice(coords))?.to_owned();
396 anyhow::ensure!(
397 !reject_reason.chars().any(char::is_control),
398 "Invalid list reject reason"
399 );
400
401 if list_order_status == list_order_status::ListOrderStatus::Reject {
402 anyhow::ensure!(
403 list_status_type == list_status_type::ListStatusType::Response
404 && !reject_reason.trim().is_empty()
405 && reject_reason != "NONE",
406 "List rejection has no definitive reason"
407 );
408 }
409
410 Ok(BinanceSpotListStatus {
411 event_time_us,
412 transaction_time_us,
413 order_list_id,
414 contingency_type,
415 list_status_type,
416 list_order_status,
417 subscription_id,
418 orders,
419 symbol,
420 list_client_order_id,
421 reject_reason,
422 })
423}
424
425fn validate_list_identifier(value: &str) -> anyhow::Result<()> {
426 anyhow::ensure!(
427 !value.trim().is_empty() && !value.chars().any(|c| c.is_whitespace() || c.is_control()),
428 "Invalid list identifier"
429 );
430 Ok(())
431}
432
433fn execution_report_min_block_length(version: u16) -> usize {
434 match version {
435 0 => EXECUTION_REPORT_BLOCK_LENGTH_V0,
436 1 | 2 => EXECUTION_REPORT_BLOCK_LENGTH_V1,
437 3..=5 => EXECUTION_REPORT_BLOCK_LENGTH_V3,
438 _ => usize::from(execution_report_event_codec::SBE_BLOCK_LENGTH),
439 }
440}
441
442pub fn decode_account_position(data: &[u8]) -> anyhow::Result<BinanceSpotAccountPositionMsg> {
458 if data.len() < HEADER_LEN {
459 anyhow::bail!(
460 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
461 data.len()
462 );
463 }
464
465 let buf = ReadBuf::new(data);
466 let block_length = buf.get_u16_at(0);
467 let template_id = buf.get_u16_at(2);
468 let schema_id = buf.get_u16_at(4);
469 let version = buf.get_u16_at(6);
470
471 if template_id != outbound_account_position_event_codec::SBE_TEMPLATE_ID {
472 anyhow::bail!(
473 "Wrong template ID: expected {}, received {template_id}",
474 outbound_account_position_event_codec::SBE_TEMPLATE_ID
475 );
476 }
477
478 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
479 anyhow::bail!(
480 "Wrong schema ID: expected {}, received {schema_id}",
481 crate::spot::sbe::spot::SBE_SCHEMA_ID
482 );
483 }
484
485 let min_block_len = if version == 0 {
486 ACCOUNT_POSITION_BLOCK_LENGTH_V0
487 } else {
488 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
489 };
490
491 if usize::from(block_length) < min_block_len {
492 anyhow::bail!(
493 "SBE account position block length too short: expected at least {min_block_len}, was {block_length}"
494 );
495 }
496
497 let min_len = HEADER_LEN + usize::from(block_length);
498 if data.len() < min_len {
499 anyhow::bail!(
500 "Buffer too short for fixed block: expected {min_len}, was {}",
501 data.len()
502 );
503 }
504
505 let group_header_end = min_len + group_size_encoding_codec::ENCODED_LENGTH;
506 if data.len() < group_header_end {
507 anyhow::bail!(
508 "Buffer too short for balances group header: expected {group_header_end}, was {}",
509 data.len()
510 );
511 }
512
513 let entry_block_length = usize::from(buf.get_u16_at(min_len));
514 if entry_block_length < ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH {
515 anyhow::bail!(
516 "SBE balances entry block length too short: expected at least {ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH}, was {entry_block_length}"
517 );
518 }
519
520 let count = GroupSizeEncoding::decode(&data[min_len..group_header_end])?.num_in_group;
521 let mut entry_offset = group_header_end;
522
523 for index in 0..count {
524 let entry_end = entry_offset + entry_block_length;
525 if data.len() < entry_end {
526 anyhow::bail!(
527 "Buffer too short for balances entry {index}: expected {entry_end}, was {}",
528 data.len()
529 );
530 }
531
532 entry_offset = validate_asset(data, entry_end)?;
533 }
534
535 let dec = outbound_account_position_event_codec::OutboundAccountPositionEventDecoder::default()
536 .wrap(buf, HEADER_LEN, block_length, version);
537
538 let event_time_us = dec.event_time();
539 let update_time_us = dec.update_time();
540
541 let mut balances_dec = dec.balances_decoder();
542 let mut balances = Vec::with_capacity(count as usize);
543
544 while let Some(_idx) = balances_dec
545 .advance()
546 .map_err(|e| anyhow::anyhow!("Failed to advance balances group: {e:?}"))?
547 {
548 let exponent = balances_dec.exponent();
549 let free_mantissa = balances_dec.free();
550 let locked_mantissa = balances_dec.locked();
551
552 let asset_coords = balances_dec.asset_decoder();
553 let asset_bytes = balances_dec.asset_slice(asset_coords);
554 let asset = Ustr::from(&String::from_utf8_lossy(asset_bytes));
555 let free = sbe_mantissa_to_decimal(free_mantissa, exponent)
556 .with_context(|| format!("failed to convert free balance for {asset}"))?;
557 let locked = sbe_mantissa_to_decimal(locked_mantissa, exponent)
558 .with_context(|| format!("failed to convert locked balance for {asset}"))?;
559
560 balances.push(BinanceSpotBalanceEntry {
561 asset,
562 free,
563 locked,
564 });
565 }
566
567 Ok(BinanceSpotAccountPositionMsg {
568 event_type: "outboundAccountPosition".to_string(),
569 event_time: us_to_ms(event_time_us),
570 last_update_time: us_to_ms(update_time_us),
571 balances,
572 })
573}
574
575pub fn decode_balance_update(data: &[u8]) -> anyhow::Result<BinanceSpotBalanceUpdateMsg> {
588 if data.len() < HEADER_LEN {
589 anyhow::bail!(
590 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
591 data.len()
592 );
593 }
594
595 let buf = ReadBuf::new(data);
596 let block_length = buf.get_u16_at(0);
597 let template_id = buf.get_u16_at(2);
598 let schema_id = buf.get_u16_at(4);
599 let version = buf.get_u16_at(6);
600
601 if template_id != balance_update_event_codec::SBE_TEMPLATE_ID {
602 anyhow::bail!(
603 "Wrong template ID: expected {}, received {template_id}",
604 balance_update_event_codec::SBE_TEMPLATE_ID
605 );
606 }
607
608 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
609 anyhow::bail!(
610 "Wrong schema ID: expected {}, received {schema_id}",
611 crate::spot::sbe::spot::SBE_SCHEMA_ID
612 );
613 }
614
615 let min_block_len = if version == 0 {
616 BALANCE_UPDATE_BLOCK_LENGTH_V0
617 } else {
618 usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH)
619 };
620
621 if usize::from(block_length) < min_block_len {
622 anyhow::bail!(
623 "SBE balance update block length too short: expected at least {min_block_len}, was {block_length}"
624 );
625 }
626
627 let min_len = HEADER_LEN + usize::from(block_length);
628 if data.len() < min_len {
629 anyhow::bail!(
630 "Buffer too short for fixed block: expected {min_len}, was {}",
631 data.len()
632 );
633 }
634
635 validate_asset(data, min_len)?;
636
637 let mut dec = balance_update_event_codec::BalanceUpdateEventDecoder::default().wrap(
638 buf,
639 HEADER_LEN,
640 block_length,
641 version,
642 );
643
644 let event_time_us = dec.event_time();
645 let clear_time_us = dec.clear_time().unwrap_or(0);
646 let qty_exponent = dec.qty_exponent();
647 let free_qty_delta = dec.free_qty_delta();
648
649 let asset = {
650 let coords = dec.asset_decoder();
651 String::from_utf8_lossy(dec.asset_slice(coords)).into_owned()
652 };
653
654 Ok(BinanceSpotBalanceUpdateMsg {
655 event_type: "balanceUpdate".to_string(),
656 event_time: us_to_ms(event_time_us),
657 asset: Ustr::from(&asset),
658 delta: mantissa_to_decimal_string(free_qty_delta, qty_exponent)?,
659 clear_time: us_to_ms(clear_time_us),
660 })
661}
662
663fn validate_asset(data: &[u8], offset: usize) -> anyhow::Result<usize> {
664 let Some(length) = data.get(offset) else {
665 anyhow::bail!(
666 "Buffer too short for asset length: expected {}, was {}",
667 offset + 1,
668 data.len()
669 );
670 };
671
672 let expected_len = offset + 1 + usize::from(*length);
673 if data.len() < expected_len {
674 anyhow::bail!(
675 "Buffer too short for asset: expected {expected_len}, was {}",
676 data.len()
677 );
678 }
679
680 Ok(expected_len)
681}
682
683fn map_execution_type(
684 et: execution_type::ExecutionType,
685) -> anyhow::Result<BinanceSpotExecutionType> {
686 match et {
687 execution_type::ExecutionType::New => Ok(BinanceSpotExecutionType::New),
688 execution_type::ExecutionType::Canceled => Ok(BinanceSpotExecutionType::Canceled),
689 execution_type::ExecutionType::Replaced => Ok(BinanceSpotExecutionType::Replaced),
690 execution_type::ExecutionType::Rejected => Ok(BinanceSpotExecutionType::Rejected),
691 execution_type::ExecutionType::Trade => Ok(BinanceSpotExecutionType::Trade),
692 execution_type::ExecutionType::Expired => Ok(BinanceSpotExecutionType::Expired),
693 execution_type::ExecutionType::TradePrevention => {
694 Ok(BinanceSpotExecutionType::TradePrevention)
695 }
696 _ => anyhow::bail!("Unsupported SBE execution type: {et}"),
697 }
698}
699
700fn map_order_status(os: order_status::OrderStatus) -> BinanceOrderStatus {
701 match os {
702 order_status::OrderStatus::New => BinanceOrderStatus::New,
703 order_status::OrderStatus::PartiallyFilled => BinanceOrderStatus::PartiallyFilled,
704 order_status::OrderStatus::Filled => BinanceOrderStatus::Filled,
705 order_status::OrderStatus::Canceled => BinanceOrderStatus::Canceled,
706 order_status::OrderStatus::PendingCancel => BinanceOrderStatus::PendingCancel,
707 order_status::OrderStatus::Rejected => BinanceOrderStatus::Rejected,
708 order_status::OrderStatus::Expired => BinanceOrderStatus::Expired,
709 order_status::OrderStatus::ExpiredInMatch => BinanceOrderStatus::ExpiredInMatch,
710 _ => BinanceOrderStatus::Unknown,
711 }
712}
713
714fn map_side(side: order_side::OrderSide) -> anyhow::Result<BinanceSide> {
715 match side {
716 order_side::OrderSide::Buy => Ok(BinanceSide::Buy),
717 order_side::OrderSide::Sell => Ok(BinanceSide::Sell),
718 _ => anyhow::bail!("Unsupported SBE order side: {side}"),
719 }
720}
721
722fn map_time_in_force(tif: time_in_force::TimeInForce) -> BinanceTimeInForce {
723 match tif {
724 time_in_force::TimeInForce::Gtc => BinanceTimeInForce::Gtc,
725 time_in_force::TimeInForce::Ioc => BinanceTimeInForce::Ioc,
726 time_in_force::TimeInForce::Fok => BinanceTimeInForce::Fok,
727 _ => BinanceTimeInForce::Unknown,
728 }
729}
730
731fn map_order_type(ot: order_type::OrderType) -> &'static str {
732 match ot {
733 order_type::OrderType::Market => "MARKET",
734 order_type::OrderType::Limit => "LIMIT",
735 order_type::OrderType::StopLoss => "STOP_LOSS",
736 order_type::OrderType::StopLossLimit => "STOP_LOSS_LIMIT",
737 order_type::OrderType::TakeProfit => "TAKE_PROFIT",
738 order_type::OrderType::TakeProfitLimit => "TAKE_PROFIT_LIMIT",
739 order_type::OrderType::LimitMaker => "LIMIT_MAKER",
740 _ => "UNKNOWN",
741 }
742}
743
744fn map_expiry_reason(reason: expiry_reason::ExpiryReason) -> Option<String> {
745 match reason {
746 expiry_reason::ExpiryReason::Rejected => Some("REJECTED".to_string()),
747 expiry_reason::ExpiryReason::ExchangeCanceled => Some("EXCHANGE_CANCELED".to_string()),
748 expiry_reason::ExpiryReason::OcoTrigger => Some("OCO_TRIGGER".to_string()),
749 expiry_reason::ExpiryReason::OtoPhaseOneExpired => {
750 Some("OTO_PHASE_ONE_EXPIRED".to_string())
751 }
752 expiry_reason::ExpiryReason::UnfilledIocQuantityExpired => {
753 Some("UNFILLED_IOC_QUANTITY_EXPIRED".to_string())
754 }
755 expiry_reason::ExpiryReason::UnfilledFokOrderExpired => {
756 Some("UNFILLED_FOK_ORDER_EXPIRED".to_string())
757 }
758 expiry_reason::ExpiryReason::InsufficientLiquidity => {
759 Some("INSUFFICIENT_LIQUIDITY".to_string())
760 }
761 expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded => {
762 Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED".to_string())
763 }
764 expiry_reason::ExpiryReason::NonRepresentable => Some("NON_REPRESENTABLE".to_string()),
765 expiry_reason::ExpiryReason::NullVal => None,
766 }
767}
768
769#[inline]
771fn us_to_ms(us: i64) -> i64 {
772 if us < 0 { us } else { us / 1_000 }
773}
774
775fn mantissa_to_decimal_string(mantissa: i64, exponent: i8) -> anyhow::Result<String> {
780 if exponent == i8::MIN {
781 anyhow::bail!("Invalid SBE decimal exponent: {exponent} is null");
782 }
783
784 let decimal_places = usize::from(exponent.unsigned_abs());
785
786 if mantissa == 0 {
787 if exponent >= 0 {
788 return Ok("0".to_string());
789 }
790 let mut s = "0.".to_string();
791 for _ in 0..decimal_places {
792 s.push('0');
793 }
794
795 return Ok(s);
796 }
797
798 let negative = mantissa < 0;
799 let abs_mantissa = mantissa.unsigned_abs();
800 let digits = abs_mantissa.to_string();
801
802 let result = if exponent >= 0 {
803 let mut s = digits;
804 for _ in 0..exponent {
805 s.push('0');
806 }
807 s
808 } else if digits.len() <= decimal_places {
809 let padding = decimal_places - digits.len();
810 let mut s = "0.".to_string();
811 for _ in 0..padding {
812 s.push('0');
813 }
814 s.push_str(&digits);
815 s
816 } else {
817 let split_pos = digits.len() - decimal_places;
818 let mut s = digits[..split_pos].to_string();
819 s.push('.');
820 s.push_str(&digits[split_pos..]);
821 s
822 };
823
824 if negative {
825 Ok(format!("-{result}"))
826 } else {
827 Ok(result)
828 }
829}
830
831#[cfg(test)]
832mod tests {
833 use nautilus_core::UnixNanos;
834 use nautilus_model::identifiers::{AccountId, InstrumentId};
835 use rstest::rstest;
836 use rust_decimal::Decimal;
837
838 use super::*;
839 use crate::spot::{
840 sbe::spot::{
841 WriteBuf, bool_enum::BoolEnum, execution_type::ExecutionType, floor, match_type,
842 order_capacity, order_side::OrderSide, order_status::OrderStatus,
843 order_type::OrderType as SbeOrderType, peg_offset_type, peg_price_type,
844 self_trade_prevention_mode::SelfTradePreventionMode,
845 time_in_force::TimeInForce as SbeTif,
846 },
847 websocket::trading::parse::parse_spot_exec_report_to_order_status,
848 };
849
850 #[expect(clippy::too_many_arguments)]
851 fn encode_execution_report(
852 symbol: &str,
853 client_order_id: &str,
854 order_id: i64,
855 trade_id: Option<i64>,
856 side: OrderSide,
857 order_type: SbeOrderType,
858 tif: SbeTif,
859 exec_type: ExecutionType,
860 status: OrderStatus,
861 price_exp: i8,
862 qty_exp: i8,
863 commission_exp: i8,
864 price_mantissa: i64,
865 orig_qty_mantissa: i64,
866 stop_price_mantissa: i64,
867 last_qty_mantissa: i64,
868 last_price_mantissa: i64,
869 executed_qty_mantissa: i64,
870 cumm_quote_qty_mantissa: i64,
871 commission_mantissa: i64,
872 commission_asset: &str,
873 is_maker: bool,
874 is_working: bool,
875 event_time_us: i64,
876 transact_time_us: i64,
877 order_creation_time_us: Option<i64>,
878 expiry_reason: expiry_reason::ExpiryReason,
879 ) -> Vec<u8> {
880 let var_data_len = 6 + symbol.len() + client_order_id.len() + commission_asset.len();
881 let total = 8 + execution_report_event_codec::SBE_BLOCK_LENGTH as usize + var_data_len;
882 let mut buf_vec = vec![0u8; total];
883
884 let buf = WriteBuf::new(buf_vec.as_mut_slice());
885 let enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
886 .wrap(buf, HEADER_LEN);
887 let mut header = enc.header(0);
888 let mut enc = header.parent().unwrap();
889
890 enc.event_time(event_time_us);
891 enc.transact_time(transact_time_us);
892 enc.price_exponent(price_exp);
893 enc.qty_exponent(qty_exp);
894 enc.commission_exponent(commission_exp);
895 enc.order_creation_time(order_creation_time_us.unwrap_or(i64::MIN));
896 enc.working_time(i64::MIN); enc.order_id(order_id);
898 enc.order_list_id(i64::MIN); enc.orig_qty(orig_qty_mantissa);
900 enc.price(price_mantissa);
901 enc.orig_quote_order_qty(0);
902 enc.iceberg_qty(0);
903 enc.stop_price(stop_price_mantissa);
904 enc.order_type(order_type);
905 enc.side(side);
906 enc.time_in_force(tif);
907 enc.execution_type(exec_type);
908 enc.order_status(status);
909 enc.trade_id(trade_id.unwrap_or(i64::MIN));
910 enc.execution_id(0);
911 enc.executed_qty(executed_qty_mantissa);
912 enc.cummulative_quote_qty(cumm_quote_qty_mantissa);
913 enc.last_qty(last_qty_mantissa);
914 enc.last_price(last_price_mantissa);
915 enc.quote_qty(0);
916 enc.commission(commission_mantissa);
917 enc.is_working(if is_working {
918 BoolEnum::True
919 } else {
920 BoolEnum::False
921 });
922 enc.is_maker(if is_maker {
923 BoolEnum::True
924 } else {
925 BoolEnum::False
926 });
927 enc.is_best_match(BoolEnum::False);
928 enc.match_type(match_type::MatchType::default());
929 enc.self_trade_prevention_mode(SelfTradePreventionMode::default());
930 enc.order_capacity(order_capacity::OrderCapacity::default());
931 enc.working_floor(floor::Floor::default());
932 enc.used_sor(BoolEnum::False);
933 enc.alloc_id(i64::MIN);
934 enc.trailing_delta(u64::MAX);
935 enc.trailing_time(i64::MIN);
936 enc.trade_group_id(i64::MIN);
937 enc.prevented_qty(0);
938 enc.last_prevented_qty(i64::MIN);
939 enc.prevented_match_id(i64::MIN);
940 enc.prevented_execution_qty(i64::MIN);
941 enc.prevented_execution_price(i64::MIN);
942 enc.prevented_execution_quote_qty(i64::MIN);
943 enc.strategy_type(i32::MIN);
944 enc.strategy_id(i64::MIN);
945 enc.counter_order_id(i64::MIN);
946 enc.subscription_id(0xFFFF); enc.peg_price_type(peg_price_type::PegPriceType::default());
948 enc.peg_offset_type(peg_offset_type::PegOffsetType::default());
949 enc.peg_offset_value(0xFF); enc.pegged_price(i64::MIN);
951 enc.expiry_reason(expiry_reason);
952
953 enc.symbol(symbol);
955 enc.client_order_id(client_order_id);
956 enc.orig_client_order_id("");
957 enc.commission_asset(commission_asset);
958 enc.reject_reason("");
959 enc.counter_symbol("");
960
961 buf_vec
962 }
963
964 fn encode_bounds_execution_report() -> Vec<u8> {
965 encode_execution_report(
966 "SYMBOL1",
967 "CLIENT2",
968 12345678,
969 Some(87654321),
970 OrderSide::Buy,
971 SbeOrderType::Limit,
972 SbeTif::Gtc,
973 ExecutionType::Trade,
974 OrderStatus::Filled,
975 -2,
976 -5,
977 -8,
978 123456,
979 234567,
980 345678,
981 456789,
982 567891,
983 678912,
984 789123,
985 891234,
986 "ASSET3",
987 true,
988 false,
989 1709654400123456,
990 1709654400234567,
991 Some(1709654400345678),
992 expiry_reason::ExpiryReason::NullVal,
993 )
994 }
995
996 fn encode_account_position(
997 event_time_us: i64,
998 update_time_us: i64,
999 balances: &[(&str, i8, i64, i64)], ) -> Vec<u8> {
1001 let var_data_len: usize = balances.iter().map(|(a, _, _, _)| 1 + a.len()).sum();
1002 let total = 8 + 18 + 6 + (balances.len() * 17) + var_data_len;
1003 let mut buf_vec = vec![0u8; total];
1004
1005 let buf = WriteBuf::new(buf_vec.as_mut_slice());
1006 let enc =
1007 outbound_account_position_event_codec::OutboundAccountPositionEventEncoder::default()
1008 .wrap(buf, HEADER_LEN);
1009 let mut header = enc.header(0);
1010 let mut enc = header.parent().unwrap();
1011
1012 enc.event_time(event_time_us);
1013 enc.update_time(update_time_us);
1014 enc.subscription_id(0xFFFF); let balances_enc =
1017 outbound_account_position_event_codec::encoder::BalancesEncoder::default();
1018 let mut bal_enc = enc.balances_encoder(balances.len() as u32, balances_enc);
1019
1020 for (asset, exponent, free, locked) in balances {
1021 bal_enc.advance().unwrap();
1022 bal_enc.exponent(*exponent);
1023 bal_enc.free(*free);
1024 bal_enc.locked(*locked);
1025 bal_enc.asset(asset);
1026 }
1027
1028 buf_vec
1029 }
1030
1031 #[rstest]
1032 #[case::executing(false)]
1033 #[case::rejected(true)]
1034 fn test_decode_list_status_exact_fixture(#[case] rejected: bool) {
1035 let name = if rejected { "rejected" } else { "executing" };
1036 let data = crate::common::testing::load_fixture_bytes(&format!(
1037 "spot/user_data_sbe/list_status_{name}.sbe"
1038 ));
1039 let msg = decode_list_status(&data).unwrap();
1040 assert_eq!(
1041 msg,
1042 BinanceSpotListStatus {
1043 event_time_us: 1_734_300_000_123_456,
1044 transaction_time_us: 1_734_300_000_111_222,
1045 order_list_id: 4401,
1046 contingency_type: contingency_type::ContingencyType::Oco,
1047 list_status_type: if rejected {
1048 list_status_type::ListStatusType::Response
1049 } else {
1050 list_status_type::ListStatusType::ExecStarted
1051 },
1052 list_order_status: if rejected {
1053 list_order_status::ListOrderStatus::Reject
1054 } else {
1055 list_order_status::ListOrderStatus::Executing
1056 },
1057 subscription_id: Some(7),
1058 orders: vec![
1059 BinanceSpotListOrder {
1060 order_id: 71001,
1061 symbol: Ustr::from("BTCUSDT"),
1062 client_order_id: "list-child-a".to_owned()
1063 },
1064 BinanceSpotListOrder {
1065 order_id: 72002,
1066 symbol: Ustr::from("BTCUSDT"),
1067 client_order_id: "list-child-b".to_owned()
1068 },
1069 ],
1070 symbol: Ustr::from("BTCUSDT"),
1071 list_client_order_id: "list-4401".to_owned(),
1072 reject_reason: if rejected {
1073 "INSUFFICIENT_BALANCES"
1074 } else {
1075 "NONE"
1076 }
1077 .to_owned(),
1078 }
1079 );
1080 }
1081
1082 #[rstest]
1083 fn test_decode_list_status_rejects_every_truncation() {
1084 for name in ["executing", "rejected"] {
1085 let data = crate::common::testing::load_fixture_bytes(&format!(
1086 "spot/user_data_sbe/list_status_{name}.sbe"
1087 ));
1088
1089 for length in 0..data.len() {
1090 assert!(
1091 decode_list_status(&data[..length]).is_err(),
1092 "Accepted {name} truncation at {length}"
1093 );
1094 }
1095 }
1096 }
1097
1098 #[rstest]
1099 #[case::template(2, 607u16)]
1100 #[case::schema(4, 2)]
1101 #[case::version(6, 6)]
1102 #[case::block(0, 28)]
1103 #[case::child_block(37, 7)]
1104 #[case::empty_group(39, 0)]
1105 #[case::excessive_count(39, u16::MAX)]
1106 fn test_decode_list_status_rejects_header(#[case] offset: usize, #[case] value: u16) {
1107 let mut data = crate::common::testing::load_fixture_bytes(
1108 "spot/user_data_sbe/list_status_executing.sbe",
1109 );
1110 data[offset..offset + 2].copy_from_slice(&value.to_le_bytes());
1111 assert!(decode_list_status(&data).is_err());
1112 }
1113
1114 #[rstest]
1115 #[case::event_negative(8, -1i64)]
1116 #[case::event_null(8, i64::MIN)]
1117 #[case::event_overflow(8, i64::MAX)]
1118 #[case::transaction_negative(16, -1)]
1119 #[case::transaction_overflow(16, i64::MAX)]
1120 #[case::list_id(24, -1)]
1121 #[case::child_id(41, -1)]
1122 fn test_decode_list_status_rejects_numeric_fields(#[case] offset: usize, #[case] value: i64) {
1123 let mut data = crate::common::testing::load_fixture_bytes(
1124 "spot/user_data_sbe/list_status_executing.sbe",
1125 );
1126 data[offset..offset + 8].copy_from_slice(&value.to_le_bytes());
1127 assert!(decode_list_status(&data).is_err());
1128 }
1129
1130 #[rstest]
1131 #[case::contingency(32)]
1132 #[case::status_type(33)]
1133 #[case::order_status(34)]
1134 #[case::symbol_utf8(50)]
1135 #[case::client_utf8(58)]
1136 fn test_decode_list_status_rejects_unknown_enum_or_invalid_utf8(#[case] offset: usize) {
1137 let mut data = crate::common::testing::load_fixture_bytes(
1138 "spot/user_data_sbe/list_status_executing.sbe",
1139 );
1140 data[offset] = 255;
1141 assert!(decode_list_status(&data).is_err());
1142 }
1143
1144 #[rstest]
1145 #[case::duplicate_venue("venue")]
1146 #[case::duplicate_client("client")]
1147 #[case::mismatched_symbol("symbol")]
1148 #[case::empty_client("empty")]
1149 #[case::broker_id("broker")]
1150 #[case::trailing("trailing")]
1151 fn test_decode_list_status_rejects_malformed_children(#[case] field: &str) {
1152 let mut data = crate::common::testing::load_fixture_bytes(
1153 "spot/user_data_sbe/list_status_executing.sbe",
1154 );
1155
1156 match field {
1157 "venue" => {
1158 let id = data[41..49].to_vec();
1159 data[70..78].copy_from_slice(&id);
1160 }
1161 "client" => {
1162 let id = data[58..70].to_vec();
1163 data[87..99].copy_from_slice(&id);
1164 }
1165 "symbol" => data[79] = b'E',
1166 "empty" => data[58] = b' ',
1167 "broker" => {
1168 data[58..70].copy_from_slice(b"x-TD67BGP9-?");
1169 }
1170 "trailing" => data.push(0),
1171 _ => unreachable!(),
1172 }
1173
1174 assert!(decode_list_status(&data).is_err());
1175 }
1176
1177 #[rstest]
1178 #[case::empty("")]
1179 #[case::none("NONE")]
1180 #[case::whitespace(" ")]
1181 #[case::control("bad\nreason")]
1182 fn test_decode_list_status_rejects_invalid_rejection_reason(#[case] reason: &str) {
1183 let mut data = crate::common::testing::load_fixture_bytes(
1184 "spot/user_data_sbe/list_status_rejected.sbe",
1185 );
1186 let suffix = "INSUFFICIENT_BALANCES";
1187 data.truncate(data.len() - suffix.len() - 1);
1188 data.push(reason.len() as u8);
1189 data.extend_from_slice(reason.as_bytes());
1190 let error = decode_list_status(&data).unwrap_err();
1191 assert_eq!(
1192 error.to_string(),
1193 if reason.contains('\n') {
1194 "Invalid list reject reason"
1195 } else {
1196 "List rejection has no definitive reason"
1197 }
1198 );
1199 }
1200
1201 #[rstest]
1202 #[case::v0(0, None)]
1203 #[case::v1(1, Some(7))]
1204 #[case::null_subscription(5, None)]
1205 fn test_decode_list_status_versions(#[case] version: u16, #[case] subscription: Option<u16>) {
1206 let mut data = crate::common::testing::load_fixture_bytes(
1207 "spot/user_data_sbe/list_status_executing.sbe",
1208 );
1209 data[6..8].copy_from_slice(&version.to_le_bytes());
1210 if version == 0 {
1211 data[0..2].copy_from_slice(&27u16.to_le_bytes());
1212 data.drain(35..37);
1213 } else if subscription.is_none() {
1214 data[35..37].copy_from_slice(&u16::MAX.to_le_bytes());
1215 }
1216
1217 assert_eq!(
1218 decode_list_status(&data).unwrap().subscription_id,
1219 subscription
1220 );
1221 }
1222
1223 #[rstest]
1224 fn test_mantissa_to_decimal_string_basic() {
1225 assert_eq!(mantissa_to_decimal_string(250000, -2).unwrap(), "2500.00");
1226 assert_eq!(
1227 mantissa_to_decimal_string(100000000, -8).unwrap(),
1228 "1.00000000"
1229 );
1230 assert_eq!(mantissa_to_decimal_string(0, -8).unwrap(), "0.00000000");
1231 assert_eq!(mantissa_to_decimal_string(0, 0).unwrap(), "0");
1232 assert_eq!(mantissa_to_decimal_string(42, 0).unwrap(), "42");
1233 assert_eq!(mantissa_to_decimal_string(42, 2).unwrap(), "4200");
1234 assert_eq!(mantissa_to_decimal_string(5, -3).unwrap(), "0.005");
1235 assert_eq!(mantissa_to_decimal_string(-250000, -2).unwrap(), "-2500.00");
1236 assert_eq!(
1237 mantissa_to_decimal_string(i64::MIN, -2).unwrap(),
1238 "-92233720368547758.08"
1239 );
1240 }
1241
1242 #[rstest]
1243 fn test_mantissa_to_decimal_string_wire_domain(
1244 #[values(i64::MIN, -250000, -1, 0, 1, 250000, i64::MAX)] mantissa: i64,
1245 ) {
1246 for exponent in i8::MIN..=i8::MAX {
1247 let result = mantissa_to_decimal_string(mantissa, exponent);
1248
1249 if exponent == i8::MIN {
1250 assert_eq!(
1251 result.unwrap_err().to_string(),
1252 "Invalid SBE decimal exponent: -128 is null"
1253 );
1254 } else {
1255 assert!(result.unwrap().len() <= 147);
1256 }
1257 }
1258
1259 let largest = mantissa_to_decimal_string(i64::MIN, i8::MAX).unwrap();
1260 let smallest = mantissa_to_decimal_string(-1, -127).unwrap();
1261
1262 assert_eq!(largest, format!("-9223372036854775808{}", "0".repeat(127)));
1263 assert_eq!(smallest, format!("-0.{}1", "0".repeat(126)));
1264 }
1265
1266 #[rstest]
1267 #[case::typical_price(250000_i64, -2_i8, "2500.00")]
1268 #[case::btc_one(100000000_i64, -8_i8, "1.00000000")]
1269 #[case::zero_negative_exp(0_i64, -8_i8, "0")]
1270 #[case::zero_zero_exp(0_i64, 0_i8, "0")]
1271 #[case::whole_no_scale(42_i64, 0_i8, "42")]
1272 #[case::positive_exponent(42_i64, 2_i8, "4200")]
1273 #[case::small_fractional(5_i64, -3_i8, "0.005")]
1274 #[case::negative_mantissa(-250000_i64, -2_i8, "-2500.00")]
1275 #[case::large_positive_exponent(1_i64, 9_i8, "1000000000")]
1276 #[case::exponent_beyond_i64_power(1_i64, 19_i8, "10000000000000000000")]
1277 #[case::max_decimal_scale(1_i64, -28_i8, "0.0000000000000000000000000001")]
1278 #[case::zero_with_large_exponent(0_i64, i8::MAX, "0")]
1279 fn test_mantissa_to_decimal_parametrized(
1280 #[case] mantissa: i64,
1281 #[case] exponent: i8,
1282 #[case] expected: &str,
1283 ) {
1284 let result = sbe_mantissa_to_decimal(mantissa, exponent).unwrap();
1285 assert_eq!(result, Decimal::from_str_exact(expected).unwrap());
1286 }
1287
1288 #[rstest]
1289 #[case::scale_above_max(1_i64, -29_i8)]
1290 #[case::min_exponent(1_i64, i8::MIN)]
1291 #[case::value_overflow(1_i64, 29_i8)]
1292 #[case::product_overflow(i64::MAX, 18_i8)]
1293 fn test_mantissa_to_decimal_out_of_range_returns_error(
1294 #[case] mantissa: i64,
1295 #[case] exponent: i8,
1296 ) {
1297 let err = sbe_mantissa_to_decimal(mantissa, exponent).unwrap_err();
1298
1299 assert!(err.to_string().contains(&exponent.to_string()));
1300 }
1301
1302 #[rstest]
1303 #[case::scale_above_max(-29_i8, 1_i64)]
1304 #[case::min_exponent(i8::MIN, 1_i64)]
1305 #[case::positive_overflow(29_i8, 1_i64)]
1306 #[case::product_overflow(18_i8, i64::MAX)]
1307 fn test_decode_account_position_rejects_unrepresentable_balance(
1308 #[case] exponent: i8,
1309 #[case] mantissa: i64,
1310 ) {
1311 let data = encode_account_position(
1312 1_709_654_400_000_000,
1313 1_709_654_400_000_000,
1314 &[
1315 ("BTC", -8, 100_000_000, 0),
1316 ("ETH", exponent, mantissa, mantissa),
1317 ],
1318 );
1319
1320 let err = decode_account_position(&data).unwrap_err();
1321 let message = format!("{err:#}");
1322
1323 assert!(message.contains("ETH"), "{message}");
1324 assert!(message.contains(&exponent.to_string()), "{message}");
1325 }
1326
1327 #[rstest]
1328 #[case::free_overflow(i64::MAX, 1_i64)]
1329 #[case::locked_overflow(1_i64, i64::MAX)]
1330 fn test_decode_account_position_rejects_one_sided_overflow(
1331 #[case] free: i64,
1332 #[case] locked: i64,
1333 ) {
1334 let data = encode_account_position(
1335 1_709_654_400_000_000,
1336 1_709_654_400_000_000,
1337 &[("ETH", 10, free, locked)],
1338 );
1339
1340 let err = decode_account_position(&data).unwrap_err();
1341 let message = format!("{err:#}");
1342
1343 assert!(message.contains("ETH"), "{message}");
1344 assert!(message.contains("10"), "{message}");
1345 }
1346
1347 #[rstest]
1348 fn test_decode_account_position_positive_exponent_is_exact() {
1349 let data = encode_account_position(
1350 1_709_654_400_000_000,
1351 1_709_654_400_000_000,
1352 &[("BTC", 2, 42, 7)],
1353 );
1354
1355 let msg = decode_account_position(&data).unwrap();
1356
1357 assert_eq!(msg.balances.len(), 1);
1358 assert_eq!(msg.balances[0].free, Decimal::from(4_200));
1359 assert_eq!(msg.balances[0].locked, Decimal::from(700));
1360 }
1361
1362 #[rstest]
1363 #[case::rejected(expiry_reason::ExpiryReason::Rejected, Some("REJECTED"))]
1364 #[case::exchange_canceled(
1365 expiry_reason::ExpiryReason::ExchangeCanceled,
1366 Some("EXCHANGE_CANCELED")
1367 )]
1368 #[case::oco_trigger(expiry_reason::ExpiryReason::OcoTrigger, Some("OCO_TRIGGER"))]
1369 #[case::oto_phase_one_expired(
1370 expiry_reason::ExpiryReason::OtoPhaseOneExpired,
1371 Some("OTO_PHASE_ONE_EXPIRED")
1372 )]
1373 #[case::unfilled_ioc_quantity_expired(
1374 expiry_reason::ExpiryReason::UnfilledIocQuantityExpired,
1375 Some("UNFILLED_IOC_QUANTITY_EXPIRED")
1376 )]
1377 #[case::unfilled_fok_order_expired(
1378 expiry_reason::ExpiryReason::UnfilledFokOrderExpired,
1379 Some("UNFILLED_FOK_ORDER_EXPIRED")
1380 )]
1381 #[case::insufficient_liquidity(
1382 expiry_reason::ExpiryReason::InsufficientLiquidity,
1383 Some("INSUFFICIENT_LIQUIDITY")
1384 )]
1385 #[case::execution_rule_price_range_exceeded(
1386 expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded,
1387 Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED")
1388 )]
1389 #[case::non_representable(
1390 expiry_reason::ExpiryReason::NonRepresentable,
1391 Some("NON_REPRESENTABLE")
1392 )]
1393 #[case::null_val(expiry_reason::ExpiryReason::NullVal, None)]
1394 fn test_map_expiry_reason(
1395 #[case] reason: expiry_reason::ExpiryReason,
1396 #[case] expected: Option<&str>,
1397 ) {
1398 let result = map_expiry_reason(reason);
1399
1400 assert_eq!(result.as_deref(), expected);
1401 }
1402
1403 #[rstest]
1404 fn test_decode_execution_report_new_limit() {
1405 let data = encode_execution_report(
1406 "ETHUSDT",
1407 "O-20200101-000000-000-000-0",
1408 12345678,
1409 None, OrderSide::Buy,
1411 SbeOrderType::Limit,
1412 SbeTif::Gtc,
1413 ExecutionType::New,
1414 OrderStatus::New,
1415 -2, -5, -8, 250000, 100000, 0, 0, 0, 0, 0, 0, "", false,
1428 true, 1709654400000000, 1709654400000000, Some(1709654400000000),
1432 expiry_reason::ExpiryReason::NullVal,
1433 );
1434
1435 let report = decode_execution_report(&data).unwrap();
1436
1437 assert_eq!(report.symbol, "ETHUSDT");
1438 assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
1439 assert_eq!(report.order_id, 12345678);
1440 assert_eq!(report.side, BinanceSide::Buy);
1441 assert_eq!(report.order_type, "LIMIT");
1442 assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
1443 assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
1444 assert_eq!(report.order_status, BinanceOrderStatus::New);
1445 assert_eq!(report.price, "2500.00");
1446 assert_eq!(report.original_qty, "1.00000");
1447 assert_eq!(report.trade_id, -1);
1448 assert!(report.is_working);
1449 assert!(!report.is_maker);
1450 assert_eq!(report.event_time, 1709654400000);
1451 assert_eq!(report.transaction_time, 1709654400000);
1452 assert!(report.expiry_reason.is_none());
1453 }
1454
1455 #[rstest]
1456 fn test_decode_execution_report_expiry_reason() {
1457 let data = encode_execution_report(
1458 "ETHUSDT",
1459 "O-20200101-000000-000-000-0",
1460 12345678,
1461 None,
1462 OrderSide::Buy,
1463 SbeOrderType::Limit,
1464 SbeTif::Ioc,
1465 ExecutionType::Expired,
1466 OrderStatus::Expired,
1467 -2,
1468 -5,
1469 -8,
1470 250000,
1471 100000,
1472 0,
1473 0,
1474 0,
1475 0,
1476 0,
1477 0,
1478 "",
1479 false,
1480 false,
1481 1709654400000000,
1482 1709654400000000,
1483 Some(1709654400000000),
1484 expiry_reason::ExpiryReason::InsufficientLiquidity,
1485 );
1486
1487 let report = decode_execution_report(&data).unwrap();
1488
1489 assert_eq!(report.execution_type, BinanceSpotExecutionType::Expired);
1490 assert_eq!(report.order_status, BinanceOrderStatus::Expired);
1491 assert_eq!(
1492 report.expiry_reason.as_deref(),
1493 Some("INSUFFICIENT_LIQUIDITY")
1494 );
1495 }
1496
1497 #[rstest]
1498 fn test_decode_execution_report_trade_fill() {
1499 let data = encode_execution_report(
1500 "ETHUSDT",
1501 "O-20200101-000000-000-000-0",
1502 12345678,
1503 Some(98765432),
1504 OrderSide::Buy,
1505 SbeOrderType::Limit,
1506 SbeTif::Gtc,
1507 ExecutionType::Trade,
1508 OrderStatus::Filled,
1509 -2, -8, -8, 250000, 100000000, 0, 100000000, 250000, 100000000, 250000, 250000, "USDT",
1521 true, false,
1523 1709654400000000,
1524 1709654400000000,
1525 Some(1709654400000000),
1526 expiry_reason::ExpiryReason::NullVal,
1527 );
1528
1529 let report = decode_execution_report(&data).unwrap();
1530
1531 let status = parse_spot_exec_report_to_order_status(
1532 &report,
1533 InstrumentId::from("ETHUSDT.BINANCE"),
1534 2,
1535 8,
1536 AccountId::from("BINANCE-001"),
1537 false,
1538 UnixNanos::default(),
1539 )
1540 .unwrap();
1541
1542 assert_eq!(report.execution_type, BinanceSpotExecutionType::Trade);
1543 assert_eq!(report.order_status, BinanceOrderStatus::Filled);
1544 assert_eq!(report.trade_id, 98765432);
1545 assert_eq!(report.last_filled_qty, "1.00000000");
1546 assert_eq!(report.last_filled_price, "2500.00");
1547 assert_eq!(report.cumulative_quote_qty, "2500.00");
1548 assert_eq!(status.avg_px.unwrap().to_string(), "2500.00");
1549 assert_eq!(report.commission_asset, Some(Ustr::from("USDT")));
1550 assert!(report.is_maker);
1551 }
1552
1553 #[rstest]
1554 fn test_decode_execution_report_canceled() {
1555 let data = encode_execution_report(
1556 "BTCUSDT",
1557 "O-20200101-000000-000-000-1",
1558 99999,
1559 None,
1560 OrderSide::Sell,
1561 SbeOrderType::Limit,
1562 SbeTif::Gtc,
1563 ExecutionType::Canceled,
1564 OrderStatus::Canceled,
1565 -2,
1566 -8,
1567 -8,
1568 5000000, 10000000, 0,
1571 0,
1572 0,
1573 0,
1574 0,
1575 0,
1576 "",
1577 false,
1578 false,
1579 1709654400000000,
1580 1709654400000000,
1581 Some(1709654400000000),
1582 expiry_reason::ExpiryReason::NullVal,
1583 );
1584
1585 let report = decode_execution_report(&data).unwrap();
1586
1587 assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1588 assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1589 assert_eq!(report.symbol, "BTCUSDT");
1590 assert_eq!(report.side, BinanceSide::Sell);
1591 }
1592
1593 #[rstest]
1594 fn test_decode_execution_report_stop_loss_limit() {
1595 let data = encode_execution_report(
1596 "ETHUSDT",
1597 "O-20200101-000000-000-000-1",
1598 12345679,
1599 None,
1600 OrderSide::Sell,
1601 SbeOrderType::StopLossLimit,
1602 SbeTif::Gtc,
1603 ExecutionType::New,
1604 OrderStatus::New,
1605 -2,
1606 -5,
1607 -8,
1608 240000, 100000, 245000, 0,
1612 0,
1613 0,
1614 0,
1615 0,
1616 "",
1617 false,
1618 true,
1619 1709654400000000,
1620 1709654400000000,
1621 Some(1709654400000000),
1622 expiry_reason::ExpiryReason::NullVal,
1623 );
1624
1625 let report = decode_execution_report(&data).unwrap();
1626
1627 assert_eq!(report.order_type, "STOP_LOSS_LIMIT");
1628 assert_eq!(report.price, "2400.00");
1629 assert_eq!(report.stop_price, "2450.00");
1630 }
1631
1632 #[rstest]
1633 fn test_decode_execution_report_truncated_header() {
1634 let data = vec![0u8; 5];
1635 let err = decode_execution_report(&data).unwrap_err();
1636 assert!(err.to_string().contains("too short for SBE header"));
1637 }
1638
1639 #[rstest]
1640 fn test_decode_execution_report_rejects_truncated_variable_data() {
1641 let data = encode_bounds_execution_report();
1642 let lengths = ["SYMBOL1".len(), "CLIENT2".len(), 0, "ASSET3".len(), 0, 0];
1643 let mut offset = HEADER_LEN + usize::from(execution_report_event_codec::SBE_BLOCK_LENGTH);
1644
1645 for (field, length) in EXECUTION_REPORT_VAR_DATA_FIELDS.into_iter().zip(lengths) {
1646 let err = decode_execution_report(&data[..offset]).unwrap_err();
1647 assert_eq!(
1648 err.to_string(),
1649 format!(
1650 "Buffer too short for {field} length: expected {}, was {offset}",
1651 offset + 1
1652 )
1653 );
1654
1655 if length > 0 {
1656 let truncated_len = offset + length;
1657 let expected_len = offset + 1 + length;
1658 let err = decode_execution_report(&data[..truncated_len]).unwrap_err();
1659 assert_eq!(
1660 err.to_string(),
1661 format!(
1662 "Buffer too short for {field}: expected {expected_len}, was {truncated_len}"
1663 )
1664 );
1665 }
1666
1667 offset += 1 + length;
1668 }
1669 }
1670
1671 #[rstest]
1672 fn test_decode_execution_report_rejects_short_declared_block() {
1673 let mut data = encode_bounds_execution_report();
1674 let block_length = execution_report_event_codec::SBE_BLOCK_LENGTH - 1;
1675 data[..2].copy_from_slice(&block_length.to_le_bytes());
1676
1677 let err = decode_execution_report(&data).unwrap_err();
1678
1679 assert_eq!(
1680 err.to_string(),
1681 format!(
1682 "SBE execution report block length too short: expected at least {EXECUTION_REPORT_BLOCK_LENGTH_V3}, was {block_length}"
1683 )
1684 );
1685 }
1686
1687 #[rstest]
1688 #[case::price(16)]
1689 #[case::qty(17)]
1690 #[case::commission(18)]
1691 fn test_decode_execution_report_rejects_null_exponent(
1692 #[case] offset: usize,
1693 #[values(0, 1)] mantissa: i64,
1694 ) {
1695 let mut data = encode_bounds_execution_report();
1696 let buf = WriteBuf::new(&mut data);
1697 let mut enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
1698 .wrap(buf, HEADER_LEN);
1699 enc.price(mantissa);
1700 enc.stop_price(mantissa);
1701 enc.last_price(mantissa);
1702 enc.orig_qty(mantissa);
1703 enc.last_qty(mantissa);
1704 enc.executed_qty(mantissa);
1705 enc.cummulative_quote_qty(mantissa);
1706 enc.commission(mantissa);
1707 data[HEADER_LEN + offset] = i8::MIN.to_le_bytes()[0];
1708
1709 let err = decode_execution_report(&data).unwrap_err();
1710
1711 assert_eq!(
1712 err.to_string(),
1713 "Invalid SBE decimal exponent: -128 is null"
1714 );
1715 }
1716
1717 #[rstest]
1718 fn test_decode_execution_report_quote_uses_price_exponent() {
1719 let mut data = encode_bounds_execution_report();
1720 let buf = WriteBuf::new(&mut data);
1721 let mut enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
1722 .wrap(buf, HEADER_LEN);
1723 enc.price_exponent(-64);
1724 enc.qty_exponent(-64);
1725 enc.cummulative_quote_qty(1);
1726
1727 let report = decode_execution_report(&data).unwrap();
1728
1729 assert_eq!(
1730 report.cumulative_quote_qty,
1731 format!("0.{}1", "0".repeat(63))
1732 );
1733 }
1734
1735 #[rstest]
1736 fn test_decode_execution_report_rejects_short_version_2_block() {
1737 let mut data = crate::common::testing::load_fixture_bytes(
1738 "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1739 );
1740 let block_length = EXECUTION_REPORT_BLOCK_LENGTH_V1 as u16 - 1;
1741 data[..2].copy_from_slice(&block_length.to_le_bytes());
1742
1743 let err = decode_execution_report(&data).unwrap_err();
1744
1745 assert_eq!(
1746 err.to_string(),
1747 format!(
1748 "SBE execution report block length too short: expected at least {EXECUTION_REPORT_BLOCK_LENGTH_V1}, was {block_length}"
1749 )
1750 );
1751 }
1752
1753 #[rstest]
1754 fn test_decode_execution_report_wrong_template() {
1755 let mut data = encode_execution_report(
1756 "TEST",
1757 "test",
1758 1,
1759 None,
1760 OrderSide::Buy,
1761 SbeOrderType::Limit,
1762 SbeTif::Gtc,
1763 ExecutionType::New,
1764 OrderStatus::New,
1765 -2,
1766 -8,
1767 -8,
1768 0,
1769 0,
1770 0,
1771 0,
1772 0,
1773 0,
1774 0,
1775 0,
1776 "",
1777 false,
1778 false,
1779 0,
1780 0,
1781 None,
1782 expiry_reason::ExpiryReason::NullVal,
1783 );
1784 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1786
1787 let err = decode_execution_report(&data).unwrap_err();
1788 assert!(err.to_string().contains("Wrong template ID"));
1789 }
1790
1791 #[rstest]
1792 fn test_decode_account_position_single_balance() {
1793 let data = encode_account_position(
1794 1709654400000000, 1709654400000000, &[("USDT", -8, 1000000000000, 50000000000)], );
1798
1799 let msg = decode_account_position(&data).unwrap();
1800
1801 assert_eq!(msg.event_type, "outboundAccountPosition");
1802 assert_eq!(msg.event_time, 1709654400000);
1803 assert_eq!(msg.balances.len(), 1);
1804 assert_eq!(msg.balances[0].asset, "USDT");
1805 assert_eq!(
1806 msg.balances[0].free,
1807 Decimal::from_str_exact("10000.00000000").unwrap()
1808 );
1809 assert_eq!(
1810 msg.balances[0].locked,
1811 Decimal::from_str_exact("500.00000000").unwrap()
1812 );
1813 }
1814
1815 #[rstest]
1816 fn test_decode_account_position_multiple_balances() {
1817 let data = encode_account_position(
1818 1709654400000000,
1819 1709654400000000,
1820 &[
1821 ("BTC", -8, 100000000, 0), ("USDT", -8, 5000000000000, 0), ],
1824 );
1825
1826 let msg = decode_account_position(&data).unwrap();
1827
1828 assert_eq!(msg.balances.len(), 2);
1829 assert_eq!(msg.balances[0].asset, "BTC");
1830 assert_eq!(
1831 msg.balances[0].free,
1832 Decimal::from_str_exact("1.00000000").unwrap()
1833 );
1834 assert_eq!(msg.balances[1].asset, "USDT");
1835 assert_eq!(
1836 msg.balances[1].free,
1837 Decimal::from_str_exact("50000.00000000").unwrap()
1838 );
1839 }
1840
1841 #[rstest]
1842 fn test_decode_account_position_zero_balances() {
1843 let data = encode_account_position(1709654400000000, 1709654400000000, &[]);
1844
1845 let msg = decode_account_position(&data).unwrap();
1846 assert!(msg.balances.is_empty());
1847 }
1848
1849 #[rstest]
1850 #[case::above_limit_empty(crate::spot::sbe::MAX_GROUP_SIZE + 1, false)]
1851 #[case::above_limit_complete(crate::spot::sbe::MAX_GROUP_SIZE + 1, true)]
1852 #[case::max_count(u32::MAX, false)]
1853 fn test_decode_account_position_rejects_excessive_count(
1854 #[case] count: u32,
1855 #[case] complete: bool,
1856 ) {
1857 let entries = if complete {
1858 vec![("BTC", -2, 12345, 6789); count as usize]
1859 } else {
1860 Vec::new()
1861 };
1862
1863 let mut data = encode_account_position(123000, 456000, &entries);
1864 let group_offset =
1865 HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
1866 data[group_offset + 2..group_offset + group_size_encoding_codec::ENCODED_LENGTH]
1867 .copy_from_slice(&count.to_le_bytes());
1868
1869 let err = decode_account_position(&data).unwrap_err();
1870
1871 assert_eq!(
1872 err.downcast_ref::<crate::spot::sbe::SbeDecodeError>(),
1873 Some(&crate::spot::sbe::SbeDecodeError::GroupSizeTooLarge {
1874 count,
1875 max: crate::spot::sbe::MAX_GROUP_SIZE,
1876 })
1877 );
1878 }
1879
1880 #[rstest]
1881 fn test_decode_account_position_accepts_maximum_count() {
1882 let entries = vec![("ASSET", -3, 12345, 6789); crate::spot::sbe::MAX_GROUP_SIZE as usize];
1883 let data = encode_account_position(123000, 456000, &entries);
1884
1885 let msg = decode_account_position(&data).unwrap();
1886
1887 assert_eq!(msg.event_type, "outboundAccountPosition");
1888 assert_eq!(msg.event_time, 123);
1889 assert_eq!(msg.last_update_time, 456);
1890 assert_eq!(msg.balances.len(), entries.len());
1891
1892 for balance in msg.balances {
1893 assert_eq!(balance.asset, "ASSET");
1894 assert_eq!(balance.free, Decimal::new(12345, 3));
1895 assert_eq!(balance.locked, Decimal::new(6789, 3));
1896 }
1897 }
1898
1899 #[rstest]
1900 fn test_decode_account_position_validates_all_assets_before_decoding() {
1901 let mut data = encode_account_position(
1902 123000,
1903 456000,
1904 &[("BTC", i8::MIN, 12345, 6789), ("USDT", -3, 4321, 8765)],
1905 );
1906 data.pop();
1907
1908 let err = decode_account_position(&data).unwrap_err();
1909
1910 assert_eq!(
1911 err.to_string(),
1912 format!(
1913 "Buffer too short for asset: expected {}, was {}",
1914 data.len() + 1,
1915 data.len()
1916 )
1917 );
1918 }
1919
1920 #[rstest]
1921 fn test_decode_account_position_truncated_header() {
1922 let data = vec![0u8; 5];
1923 let err = decode_account_position(&data).unwrap_err();
1924 assert!(err.to_string().contains("too short for SBE header"));
1925 }
1926
1927 #[rstest]
1928 fn test_decode_account_position_wrong_template() {
1929 let mut data = encode_account_position(0, 0, &[]);
1930 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1931
1932 let err = decode_account_position(&data).unwrap_err();
1933 assert!(err.to_string().contains("Wrong template ID"));
1934 }
1935
1936 #[rstest]
1937 #[case::zero_v0(0, 0)]
1938 #[case::short_v0(0, ACCOUNT_POSITION_BLOCK_LENGTH_V0 as u16 - 1)]
1939 #[case::zero_v1(1, 0)]
1940 #[case::v0_width_in_v1(1, ACCOUNT_POSITION_BLOCK_LENGTH_V0 as u16)]
1941 #[case::short_v1(1, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1942 #[case::short_current(5, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1943 #[case::short_future(6, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1944 fn test_decode_account_position_rejects_short_declared_block(
1945 #[case] version: u16,
1946 #[case] block_length: u16,
1947 ) {
1948 let mut data = encode_account_position(123000, 456000, &[("BTC", -2, 12345, 6789)]);
1949 data[..2].copy_from_slice(&block_length.to_le_bytes());
1950 data[6..8].copy_from_slice(&version.to_le_bytes());
1951
1952 let min_block_len = if version == 0 {
1953 ACCOUNT_POSITION_BLOCK_LENGTH_V0
1954 } else {
1955 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
1956 };
1957
1958 let err = decode_account_position(&data).unwrap_err();
1959
1960 assert_eq!(
1961 err.to_string(),
1962 format!(
1963 "SBE account position block length too short: expected at least {min_block_len}, was {block_length}"
1964 )
1965 );
1966 }
1967
1968 #[rstest]
1969 fn test_decode_account_position_rejects_truncated_framing() {
1970 let data = encode_account_position(
1971 123000,
1972 456000,
1973 &[("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)],
1974 );
1975
1976 for length in 0..data.len() {
1977 assert!(
1978 decode_account_position(&data[..length]).is_err(),
1979 "Accepted truncated account position of length {length}"
1980 );
1981 }
1982 }
1983
1984 #[rstest]
1985 #[case::zero_empty(0, 0)]
1986 #[case::zero_nonempty(0, 1)]
1987 #[case::short_empty(ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH as u16 - 1, 0)]
1988 #[case::short_nonempty(ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH as u16 - 1, 1)]
1989 fn test_decode_account_position_rejects_short_entry_block(
1990 #[case] block_length: u16,
1991 #[case] count: u32,
1992 ) {
1993 let mut data = encode_account_position(123000, 456000, &[("BTC", -2, 12345, 6789)]);
1994 let group_offset =
1995 HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
1996 data[group_offset..group_offset + 2].copy_from_slice(&block_length.to_le_bytes());
1997 data[group_offset + 2..group_offset + group_size_encoding_codec::ENCODED_LENGTH]
1998 .copy_from_slice(&count.to_le_bytes());
1999
2000 let err = decode_account_position(&data).unwrap_err();
2001
2002 assert_eq!(
2003 err.to_string(),
2004 format!(
2005 "SBE balances entry block length too short: expected at least {ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH}, was {block_length}"
2006 )
2007 );
2008 }
2009
2010 #[rstest]
2011 #[case::first(0)]
2012 #[case::second(1)]
2013 fn test_decode_account_position_rejects_oversized_asset(#[case] index: usize) {
2014 let mut data = encode_account_position(
2015 123000,
2016 456000,
2017 &[("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)],
2018 );
2019 let mut asset_offset = HEADER_LEN
2020 + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
2021 + group_size_encoding_codec::ENCODED_LENGTH
2022 + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
2023
2024 if index == 1 {
2025 asset_offset += 1 + "BTC".len() + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
2026 }
2027
2028 data[asset_offset] = u8::MAX;
2029
2030 let err = decode_account_position(&data).unwrap_err();
2031
2032 assert_eq!(
2033 err.to_string(),
2034 format!(
2035 "Buffer too short for asset: expected {}, was {}",
2036 asset_offset + 1 + usize::from(u8::MAX),
2037 data.len()
2038 )
2039 );
2040 }
2041
2042 #[rstest]
2043 #[case::v0(
2044 0,
2045 ACCOUNT_POSITION_BLOCK_LENGTH_V0,
2046 ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
2047 )]
2048 #[case::v1(
2049 1,
2050 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH),
2051 ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
2052 )]
2053 #[case::current(
2054 5,
2055 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH),
2056 ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
2057 )]
2058 #[case::fixed_extension(5, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH) + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH)]
2059 #[case::entry_extension(5, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH), ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
2060 #[case::v0_extensions(0, ACCOUNT_POSITION_BLOCK_LENGTH_V0 + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
2061 #[case::future_extensions(6, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH) + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
2062 fn test_decode_account_position_accepts_versioned_blocks(
2063 #[case] version: u16,
2064 #[case] block_length: usize,
2065 #[case] entry_block_length: usize,
2066 ) {
2067 let balances = [("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)];
2068 let original = encode_account_position(123000, 456000, &balances);
2069 let group_offset =
2070 HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
2071 let mut data = original[..HEADER_LEN + ACCOUNT_POSITION_BLOCK_LENGTH_V0].to_vec();
2072 data.resize(HEADER_LEN + block_length, 0xA5);
2073 data[..2].copy_from_slice(&(block_length as u16).to_le_bytes());
2074 data[6..8].copy_from_slice(&version.to_le_bytes());
2075 data.extend_from_slice(
2076 &original[group_offset..group_offset + group_size_encoding_codec::ENCODED_LENGTH],
2077 );
2078 data[HEADER_LEN + block_length..HEADER_LEN + block_length + 2]
2079 .copy_from_slice(&(entry_block_length as u16).to_le_bytes());
2080 let mut entry_offset = group_offset + group_size_encoding_codec::ENCODED_LENGTH;
2081
2082 for (asset, _, _, _) in balances {
2083 data.extend_from_slice(
2084 &original[entry_offset..entry_offset + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH],
2085 );
2086 data.resize(
2087 data.len() + entry_block_length - ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH,
2088 0xA5,
2089 );
2090 let asset_offset = entry_offset + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
2091 entry_offset = asset_offset + 1 + asset.len();
2092 data.extend_from_slice(&original[asset_offset..entry_offset]);
2093 }
2094
2095 let msg = decode_account_position(&data).unwrap();
2096
2097 assert_eq!(msg.event_type, "outboundAccountPosition");
2098 assert_eq!(msg.event_time, 123);
2099 assert_eq!(msg.last_update_time, 456);
2100 assert_eq!(msg.balances.len(), balances.len());
2101
2102 for (balance, (asset, exponent, free, locked)) in msg.balances.iter().zip(balances) {
2103 assert_eq!(balance.asset, asset);
2104 assert_eq!(balance.free, Decimal::new(free, (-exponent) as u32));
2105 assert_eq!(balance.locked, Decimal::new(locked, (-exponent) as u32));
2106 }
2107 }
2108
2109 fn encode_balance_update(
2110 event_time_us: i64,
2111 clear_time_us: i64,
2112 qty_exponent: i8,
2113 free_qty_delta: i64,
2114 asset: &str,
2115 ) -> Vec<u8> {
2116 let total = 8 + 27 + 1 + asset.len();
2117 let mut buf_vec = vec![0u8; total];
2118
2119 let buf = WriteBuf::new(buf_vec.as_mut_slice());
2120 let enc =
2121 balance_update_event_codec::BalanceUpdateEventEncoder::default().wrap(buf, HEADER_LEN);
2122 let mut header = enc.header(0);
2123 let mut enc = header.parent().unwrap();
2124
2125 enc.event_time(event_time_us);
2126 enc.clear_time(clear_time_us);
2127 enc.qty_exponent(qty_exponent);
2128 enc.free_qty_delta(free_qty_delta);
2129 enc.subscription_id(0xFFFF); enc.asset(asset);
2131
2132 buf_vec
2133 }
2134
2135 #[rstest]
2136 fn test_decode_balance_update() {
2137 let data = encode_balance_update(
2138 1709654400000000, 1709654400000000, -8,
2141 10000000000, "BTC",
2143 );
2144
2145 let msg = decode_balance_update(&data).unwrap();
2146
2147 assert_eq!(msg.event_type, "balanceUpdate");
2148 assert_eq!(msg.event_time, 1709654400000);
2149 assert_eq!(msg.asset, "BTC");
2150 assert_eq!(msg.delta, "100.00000000");
2151 assert_eq!(msg.clear_time, 1709654400000);
2152 }
2153
2154 #[rstest]
2155 fn test_decode_balance_update_rejects_null_exponent(#[values(0, 1, -1)] mantissa: i64) {
2156 let data = encode_balance_update(123000, 456000, i8::MIN, mantissa, "ASSET");
2157
2158 let err = decode_balance_update(&data).unwrap_err();
2159
2160 assert_eq!(
2161 err.to_string(),
2162 "Invalid SBE decimal exponent: -128 is null"
2163 );
2164 }
2165
2166 #[rstest]
2167 fn test_decode_balance_update_truncated_header() {
2168 let data = vec![0u8; 5];
2169 let err = decode_balance_update(&data).unwrap_err();
2170 assert!(err.to_string().contains("too short for SBE header"));
2171 }
2172
2173 #[rstest]
2174 fn test_decode_balance_update_wrong_template() {
2175 let mut data = encode_balance_update(0, 0, -8, 0, "BTC");
2176 data[2..4].copy_from_slice(&50u16.to_le_bytes());
2177
2178 let err = decode_balance_update(&data).unwrap_err();
2179 assert!(err.to_string().contains("Wrong template ID"));
2180 }
2181
2182 #[rstest]
2183 #[case::zero_v0(0, 0)]
2184 #[case::short_v0(0, BALANCE_UPDATE_BLOCK_LENGTH_V0 as u16 - 1)]
2185 #[case::zero_v1(1, 0)]
2186 #[case::v0_width_in_v1(1, BALANCE_UPDATE_BLOCK_LENGTH_V0 as u16)]
2187 #[case::short_v1(1, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
2188 #[case::short_current(5, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
2189 #[case::short_future(6, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
2190 fn test_decode_balance_update_rejects_short_declared_block(
2191 #[case] version: u16,
2192 #[case] block_length: u16,
2193 ) {
2194 let mut data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
2195 data[..2].copy_from_slice(&block_length.to_le_bytes());
2196 data[6..8].copy_from_slice(&version.to_le_bytes());
2197
2198 let min_block_len = if version == 0 {
2199 BALANCE_UPDATE_BLOCK_LENGTH_V0
2200 } else {
2201 usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH)
2202 };
2203
2204 let err = decode_balance_update(&data).unwrap_err();
2205
2206 assert_eq!(
2207 err.to_string(),
2208 format!(
2209 "SBE balance update block length too short: expected at least {min_block_len}, was {block_length}"
2210 )
2211 );
2212 }
2213
2214 #[rstest]
2215 fn test_decode_balance_update_rejects_truncated_framing() {
2216 let data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
2217
2218 for length in 0..data.len() {
2219 assert!(
2220 decode_balance_update(&data[..length]).is_err(),
2221 "Accepted length {length}"
2222 );
2223 }
2224 }
2225
2226 #[rstest]
2227 fn test_decode_balance_update_rejects_oversized_asset() {
2228 let mut data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
2229 let asset_offset = HEADER_LEN + usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH);
2230 data[asset_offset] = u8::MAX;
2231
2232 let err = decode_balance_update(&data).unwrap_err();
2233
2234 assert_eq!(
2235 err.to_string(),
2236 format!(
2237 "Buffer too short for asset: expected {}, was {}",
2238 asset_offset + 1 + usize::from(u8::MAX),
2239 data.len()
2240 )
2241 );
2242 }
2243
2244 #[rstest]
2245 #[case::v0(0, BALANCE_UPDATE_BLOCK_LENGTH_V0)]
2246 #[case::v1(1, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH))]
2247 #[case::current(5, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH))]
2248 #[case::v0_extension(0, BALANCE_UPDATE_BLOCK_LENGTH_V0 + 4)]
2249 #[case::current_extension(5, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH) + 4)]
2250 #[case::future_extension(6, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH) + 4)]
2251 fn test_decode_balance_update_accepts_versioned_blocks(
2252 #[case] version: u16,
2253 #[case] block_length: usize,
2254 ) {
2255 let original = encode_balance_update(123000, 456000, -2, 12345, "BTC");
2256 let asset_offset = HEADER_LEN + usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH);
2257 let mut data = original[..HEADER_LEN + BALANCE_UPDATE_BLOCK_LENGTH_V0].to_vec();
2258 data.resize(HEADER_LEN + block_length, 0xA5);
2259 data[..2].copy_from_slice(&(block_length as u16).to_le_bytes());
2260 data[6..8].copy_from_slice(&version.to_le_bytes());
2261 data.extend_from_slice(&original[asset_offset..]);
2262
2263 let msg = decode_balance_update(&data).unwrap();
2264
2265 assert_eq!(msg.event_type, "balanceUpdate");
2266 assert_eq!(msg.event_time, 123);
2267 assert_eq!(msg.clear_time, 456);
2268 assert_eq!(msg.asset, "BTC");
2269 assert_eq!(msg.delta, "123.45");
2270 }
2271
2272 #[rstest]
2273 fn test_us_to_ms() {
2274 assert_eq!(us_to_ms(1_709_654_400_000_000), 1_709_654_400_000);
2275 assert_eq!(us_to_ms(1_709_654_400_123_456), 1_709_654_400_123);
2276 }
2277
2278 #[rstest]
2279 fn test_us_to_ms_preserves_negative_submillisecond_value() {
2280 assert_eq!(us_to_ms(-1), -1);
2281 }
2282
2283 #[rstest]
2284 fn test_decode_captured_execution_report_new() {
2285 let data = crate::common::testing::load_fixture_bytes(
2286 "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
2287 );
2288 let report = decode_execution_report(&data).unwrap();
2289
2290 assert_eq!(report.symbol, "BTCUSDT");
2291 assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
2292 assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
2293 assert_eq!(report.order_status, BinanceOrderStatus::New);
2294 assert_eq!(report.side, BinanceSide::Buy);
2295 assert_eq!(report.order_type, "LIMIT");
2296 assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
2297 assert_eq!(report.order_id, 12345678);
2298 assert!(report.is_working);
2299 assert!(!report.is_maker);
2300 assert_eq!(report.trade_id, -1);
2301 }
2302
2303 #[rstest]
2304 fn test_decode_captured_execution_report_canceled() {
2305 let data = crate::common::testing::load_fixture_bytes(
2306 "spot/user_data_sbe/mainnet/execution_report_event_2.sbe",
2307 );
2308 let report = decode_execution_report(&data).unwrap();
2309
2310 assert_eq!(report.symbol, "BTCUSDT");
2311 assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
2312 assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
2313 assert_eq!(report.order_id, 12345678);
2314 assert!(!report.is_working);
2315 }
2316
2317 #[rstest]
2318 fn test_decode_captured_account_position() {
2319 let data = crate::common::testing::load_fixture_bytes(
2320 "spot/user_data_sbe/mainnet/outbound_account_position_event_1.sbe",
2321 );
2322 let msg = decode_account_position(&data).unwrap();
2323
2324 assert_eq!(msg.event_type, "outboundAccountPosition");
2325 assert_eq!(msg.balances.len(), 3);
2326 assert_eq!(msg.balances[0].asset, "BTC");
2327 assert_eq!(
2328 msg.balances[0].free,
2329 Decimal::from_str_exact("1.00000000").unwrap()
2330 );
2331 assert_eq!(msg.balances[1].asset, "BNB");
2332 assert_eq!(msg.balances[2].asset, "USDT");
2333 assert_eq!(
2334 msg.balances[2].free,
2335 Decimal::from_str_exact("50000.00000000").unwrap()
2336 );
2337 }
2338}