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, group_size_encoding_codec, message_header_codec, order_side,
35 order_status, order_type, outbound_account_position_event_codec, time_in_force,
36 },
37};
38
39const HEADER_LEN: usize = message_header_codec::ENCODED_LENGTH;
40const ACCOUNT_POSITION_BLOCK_LENGTH_V0: usize = 16;
41const ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH: usize = 17;
42const BALANCE_UPDATE_BLOCK_LENGTH_V0: usize = 25;
43
44const EXECUTION_REPORT_BLOCK_LENGTH_V0: usize = 268;
46const EXECUTION_REPORT_BLOCK_LENGTH_V1: usize = 281;
47const EXECUTION_REPORT_BLOCK_LENGTH_V3: usize = 282;
48const EXECUTION_REPORT_VAR_DATA_FIELDS: [&str; 6] = [
49 "symbol",
50 "client_order_id",
51 "orig_client_order_id",
52 "commission_asset",
53 "reject_reason",
54 "counter_symbol",
55];
56
57pub fn decode_execution_report(data: &[u8]) -> anyhow::Result<BinanceSpotExecutionReport> {
66 if data.len() < HEADER_LEN {
67 anyhow::bail!(
68 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
69 data.len()
70 );
71 }
72
73 let buf = ReadBuf::new(data);
74 let block_length = buf.get_u16_at(0);
75 let template_id = buf.get_u16_at(2);
76 let schema_id = buf.get_u16_at(4);
77 let version = buf.get_u16_at(6);
78
79 if template_id != execution_report_event_codec::SBE_TEMPLATE_ID {
80 anyhow::bail!(
81 "Wrong template ID: expected {}, received {template_id}",
82 execution_report_event_codec::SBE_TEMPLATE_ID
83 );
84 }
85
86 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
87 anyhow::bail!(
88 "Wrong schema ID: expected {}, received {schema_id}",
89 crate::spot::sbe::spot::SBE_SCHEMA_ID
90 );
91 }
92
93 let min_block_len = execution_report_min_block_length(version);
94 if usize::from(block_length) < min_block_len {
95 anyhow::bail!(
96 "SBE execution report block length too short: expected at least {min_block_len}, was {block_length}"
97 );
98 }
99
100 let min_len = HEADER_LEN + usize::from(block_length);
101 if data.len() < min_len {
102 anyhow::bail!(
103 "Buffer too short for fixed block: expected {min_len}, was {}",
104 data.len()
105 );
106 }
107
108 let mut field_offset = min_len;
109 for field in EXECUTION_REPORT_VAR_DATA_FIELDS {
110 let Some(length) = data.get(field_offset) else {
111 anyhow::bail!(
112 "Buffer too short for {field} length: expected {}, was {}",
113 field_offset + 1,
114 data.len()
115 );
116 };
117 let expected_len = field_offset + 1 + usize::from(*length);
118 if data.len() < expected_len {
119 anyhow::bail!(
120 "Buffer too short for {field}: expected {expected_len}, was {}",
121 data.len()
122 );
123 }
124 field_offset = expected_len;
125 }
126
127 let mut dec = execution_report_event_codec::ExecutionReportEventDecoder::default().wrap(
128 buf,
129 HEADER_LEN,
130 block_length,
131 version,
132 );
133
134 let price_exp = dec.price_exponent();
135 let qty_exp = dec.qty_exponent();
136 let commission_exp = dec.commission_exponent();
137
138 let event_time_us = dec.event_time();
139 let transact_time_us = dec.transact_time();
140 let order_creation_time_us = dec.order_creation_time();
141 let order_id = dec.order_id();
142 let trade_id = dec.trade_id().unwrap_or(-1);
143
144 let execution_type = map_execution_type(dec.execution_type())?;
145 let order_status = map_order_status(dec.order_status());
146 let side = map_side(dec.side())?;
147 let time_in_force = map_time_in_force(dec.time_in_force());
148 let order_type_str = map_order_type(dec.order_type());
149 let expiry_reason = map_expiry_reason(dec.expiry_reason());
150 let is_working = dec.is_working() == bool_enum::BoolEnum::True;
151 let is_maker = dec.is_maker() == bool_enum::BoolEnum::True;
152
153 let price_mantissa = dec.price();
154 let orig_qty_mantissa = dec.orig_qty();
155 let stop_price_mantissa = dec.stop_price();
156 let last_qty_mantissa = dec.last_qty();
157 let last_price_mantissa = dec.last_price();
158 let executed_qty_mantissa = dec.executed_qty();
159 let cummulative_quote_qty_mantissa = dec.cummulative_quote_qty();
160 let commission_mantissa = dec.commission();
161
162 let symbol = {
165 let coords = dec.symbol_decoder();
166 String::from_utf8_lossy(dec.symbol_slice(coords)).into_owned()
167 };
168
169 let client_order_id = {
170 let coords = dec.client_order_id_decoder();
171 String::from_utf8_lossy(dec.client_order_id_slice(coords)).into_owned()
172 };
173
174 let orig_client_order_id = {
175 let coords = dec.orig_client_order_id_decoder();
176 let s = String::from_utf8_lossy(dec.orig_client_order_id_slice(coords)).into_owned();
177 if s.is_empty() { None } else { Some(s) }
178 };
179
180 let commission_asset = {
181 let coords = dec.commission_asset_decoder();
182 let bytes = dec.commission_asset_slice(coords);
183 if bytes.is_empty() {
184 None
185 } else {
186 Some(Ustr::from(&String::from_utf8_lossy(bytes)))
187 }
188 };
189
190 let reject_reason = {
191 let coords = dec.reject_reason_decoder();
192 String::from_utf8_lossy(dec.reject_reason_slice(coords)).into_owned()
193 };
194
195 let _counter_symbol_coords = dec.counter_symbol_decoder();
197
198 Ok(BinanceSpotExecutionReport {
199 event_type: "executionReport".to_string(),
200 event_time: us_to_ms(event_time_us),
201 symbol: Ustr::from(&symbol),
202 client_order_id,
203 side,
204 order_type: order_type_str.to_string(),
205 time_in_force,
206 original_qty: mantissa_to_decimal_string(orig_qty_mantissa, qty_exp),
207 price: mantissa_to_decimal_string(price_mantissa, price_exp),
208 stop_price: mantissa_to_decimal_string(stop_price_mantissa, price_exp),
209 execution_type,
210 order_status,
211 reject_reason,
212 order_id,
213 last_filled_qty: mantissa_to_decimal_string(last_qty_mantissa, qty_exp),
214 cumulative_filled_qty: mantissa_to_decimal_string(executed_qty_mantissa, qty_exp),
215 last_filled_price: mantissa_to_decimal_string(last_price_mantissa, price_exp),
216 commission: mantissa_to_decimal_string(commission_mantissa, commission_exp),
217 commission_asset,
218 transaction_time: us_to_ms(transact_time_us),
219 trade_id,
220 is_working,
221 is_maker,
222 order_creation_time: order_creation_time_us.map_or(0, us_to_ms),
223 cumulative_quote_qty: mantissa_to_decimal_string(
224 cummulative_quote_qty_mantissa,
225 price_exp + qty_exp,
226 ),
227 original_client_order_id: orig_client_order_id,
228 expiry_reason,
229 })
230}
231
232fn execution_report_min_block_length(version: u16) -> usize {
233 match version {
234 0 => EXECUTION_REPORT_BLOCK_LENGTH_V0,
235 1 | 2 => EXECUTION_REPORT_BLOCK_LENGTH_V1,
236 3..=5 => EXECUTION_REPORT_BLOCK_LENGTH_V3,
237 _ => usize::from(execution_report_event_codec::SBE_BLOCK_LENGTH),
238 }
239}
240
241pub fn decode_account_position(data: &[u8]) -> anyhow::Result<BinanceSpotAccountPositionMsg> {
251 if data.len() < HEADER_LEN {
252 anyhow::bail!(
253 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
254 data.len()
255 );
256 }
257
258 let buf = ReadBuf::new(data);
259 let block_length = buf.get_u16_at(0);
260 let template_id = buf.get_u16_at(2);
261 let schema_id = buf.get_u16_at(4);
262 let version = buf.get_u16_at(6);
263
264 if template_id != outbound_account_position_event_codec::SBE_TEMPLATE_ID {
265 anyhow::bail!(
266 "Wrong template ID: expected {}, received {template_id}",
267 outbound_account_position_event_codec::SBE_TEMPLATE_ID
268 );
269 }
270
271 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
272 anyhow::bail!(
273 "Wrong schema ID: expected {}, received {schema_id}",
274 crate::spot::sbe::spot::SBE_SCHEMA_ID
275 );
276 }
277
278 let min_block_len = if version == 0 {
279 ACCOUNT_POSITION_BLOCK_LENGTH_V0
280 } else {
281 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
282 };
283
284 if usize::from(block_length) < min_block_len {
285 anyhow::bail!(
286 "SBE account position block length too short: expected at least {min_block_len}, was {block_length}"
287 );
288 }
289
290 let min_len = HEADER_LEN + usize::from(block_length);
291 if data.len() < min_len {
292 anyhow::bail!(
293 "Buffer too short for fixed block: expected {min_len}, was {}",
294 data.len()
295 );
296 }
297
298 let group_header_end = min_len + group_size_encoding_codec::ENCODED_LENGTH;
299 if data.len() < group_header_end {
300 anyhow::bail!(
301 "Buffer too short for balances group header: expected {group_header_end}, was {}",
302 data.len()
303 );
304 }
305
306 let entry_block_length = usize::from(buf.get_u16_at(min_len));
307 if entry_block_length < ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH {
308 anyhow::bail!(
309 "SBE balances entry block length too short: expected at least {ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH}, was {entry_block_length}"
310 );
311 }
312
313 let count = buf.get_u32_at(min_len + 2);
314 let mut entry_offset = group_header_end;
315
316 for index in 0..count {
317 let entry_end = entry_offset + entry_block_length;
318 if data.len() < entry_end {
319 anyhow::bail!(
320 "Buffer too short for balances entry {index}: expected {entry_end}, was {}",
321 data.len()
322 );
323 }
324
325 entry_offset = validate_asset(data, entry_end)?;
326 }
327
328 let dec = outbound_account_position_event_codec::OutboundAccountPositionEventDecoder::default()
329 .wrap(buf, HEADER_LEN, block_length, version);
330
331 let event_time_us = dec.event_time();
332 let update_time_us = dec.update_time();
333
334 let mut balances_dec = dec.balances_decoder();
335 let count = balances_dec.count() as usize;
336 let mut balances = Vec::with_capacity(count);
337
338 while let Some(_idx) = balances_dec
339 .advance()
340 .map_err(|e| anyhow::anyhow!("Failed to advance balances group: {e:?}"))?
341 {
342 let exponent = balances_dec.exponent();
343 let free_mantissa = balances_dec.free();
344 let locked_mantissa = balances_dec.locked();
345
346 let asset_coords = balances_dec.asset_decoder();
347 let asset_bytes = balances_dec.asset_slice(asset_coords);
348 let asset = Ustr::from(&String::from_utf8_lossy(asset_bytes));
349
350 balances.push(BinanceSpotBalanceEntry {
351 asset,
352 free: mantissa_to_decimal(free_mantissa, exponent),
353 locked: mantissa_to_decimal(locked_mantissa, exponent),
354 });
355 }
356
357 Ok(BinanceSpotAccountPositionMsg {
358 event_type: "outboundAccountPosition".to_string(),
359 event_time: us_to_ms(event_time_us),
360 last_update_time: us_to_ms(update_time_us),
361 balances,
362 })
363}
364
365pub fn decode_balance_update(data: &[u8]) -> anyhow::Result<BinanceSpotBalanceUpdateMsg> {
374 if data.len() < HEADER_LEN {
375 anyhow::bail!(
376 "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
377 data.len()
378 );
379 }
380
381 let buf = ReadBuf::new(data);
382 let block_length = buf.get_u16_at(0);
383 let template_id = buf.get_u16_at(2);
384 let schema_id = buf.get_u16_at(4);
385 let version = buf.get_u16_at(6);
386
387 if template_id != balance_update_event_codec::SBE_TEMPLATE_ID {
388 anyhow::bail!(
389 "Wrong template ID: expected {}, received {template_id}",
390 balance_update_event_codec::SBE_TEMPLATE_ID
391 );
392 }
393
394 if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
395 anyhow::bail!(
396 "Wrong schema ID: expected {}, received {schema_id}",
397 crate::spot::sbe::spot::SBE_SCHEMA_ID
398 );
399 }
400
401 let min_block_len = if version == 0 {
402 BALANCE_UPDATE_BLOCK_LENGTH_V0
403 } else {
404 usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH)
405 };
406
407 if usize::from(block_length) < min_block_len {
408 anyhow::bail!(
409 "SBE balance update block length too short: expected at least {min_block_len}, was {block_length}"
410 );
411 }
412
413 let min_len = HEADER_LEN + usize::from(block_length);
414 if data.len() < min_len {
415 anyhow::bail!(
416 "Buffer too short for fixed block: expected {min_len}, was {}",
417 data.len()
418 );
419 }
420
421 validate_asset(data, min_len)?;
422
423 let mut dec = balance_update_event_codec::BalanceUpdateEventDecoder::default().wrap(
424 buf,
425 HEADER_LEN,
426 block_length,
427 version,
428 );
429
430 let event_time_us = dec.event_time();
431 let clear_time_us = dec.clear_time().unwrap_or(0);
432 let qty_exponent = dec.qty_exponent();
433 let free_qty_delta = dec.free_qty_delta();
434
435 let asset = {
436 let coords = dec.asset_decoder();
437 String::from_utf8_lossy(dec.asset_slice(coords)).into_owned()
438 };
439
440 Ok(BinanceSpotBalanceUpdateMsg {
441 event_type: "balanceUpdate".to_string(),
442 event_time: us_to_ms(event_time_us),
443 asset: Ustr::from(&asset),
444 delta: mantissa_to_decimal_string(free_qty_delta, qty_exponent),
445 clear_time: us_to_ms(clear_time_us),
446 })
447}
448
449fn validate_asset(data: &[u8], offset: usize) -> anyhow::Result<usize> {
450 let Some(length) = data.get(offset) else {
451 anyhow::bail!(
452 "Buffer too short for asset length: expected {}, was {}",
453 offset + 1,
454 data.len()
455 );
456 };
457
458 let expected_len = offset + 1 + usize::from(*length);
459 if data.len() < expected_len {
460 anyhow::bail!(
461 "Buffer too short for asset: expected {expected_len}, was {}",
462 data.len()
463 );
464 }
465
466 Ok(expected_len)
467}
468
469fn map_execution_type(
470 et: execution_type::ExecutionType,
471) -> anyhow::Result<BinanceSpotExecutionType> {
472 match et {
473 execution_type::ExecutionType::New => Ok(BinanceSpotExecutionType::New),
474 execution_type::ExecutionType::Canceled => Ok(BinanceSpotExecutionType::Canceled),
475 execution_type::ExecutionType::Replaced => Ok(BinanceSpotExecutionType::Replaced),
476 execution_type::ExecutionType::Rejected => Ok(BinanceSpotExecutionType::Rejected),
477 execution_type::ExecutionType::Trade => Ok(BinanceSpotExecutionType::Trade),
478 execution_type::ExecutionType::Expired => Ok(BinanceSpotExecutionType::Expired),
479 execution_type::ExecutionType::TradePrevention => {
480 Ok(BinanceSpotExecutionType::TradePrevention)
481 }
482 _ => anyhow::bail!("Unsupported SBE execution type: {et}"),
483 }
484}
485
486fn map_order_status(os: order_status::OrderStatus) -> BinanceOrderStatus {
487 match os {
488 order_status::OrderStatus::New => BinanceOrderStatus::New,
489 order_status::OrderStatus::PartiallyFilled => BinanceOrderStatus::PartiallyFilled,
490 order_status::OrderStatus::Filled => BinanceOrderStatus::Filled,
491 order_status::OrderStatus::Canceled => BinanceOrderStatus::Canceled,
492 order_status::OrderStatus::PendingCancel => BinanceOrderStatus::PendingCancel,
493 order_status::OrderStatus::Rejected => BinanceOrderStatus::Rejected,
494 order_status::OrderStatus::Expired => BinanceOrderStatus::Expired,
495 order_status::OrderStatus::ExpiredInMatch => BinanceOrderStatus::ExpiredInMatch,
496 _ => BinanceOrderStatus::Unknown,
497 }
498}
499
500fn map_side(side: order_side::OrderSide) -> anyhow::Result<BinanceSide> {
501 match side {
502 order_side::OrderSide::Buy => Ok(BinanceSide::Buy),
503 order_side::OrderSide::Sell => Ok(BinanceSide::Sell),
504 _ => anyhow::bail!("Unsupported SBE order side: {side}"),
505 }
506}
507
508fn map_time_in_force(tif: time_in_force::TimeInForce) -> BinanceTimeInForce {
509 match tif {
510 time_in_force::TimeInForce::Gtc => BinanceTimeInForce::Gtc,
511 time_in_force::TimeInForce::Ioc => BinanceTimeInForce::Ioc,
512 time_in_force::TimeInForce::Fok => BinanceTimeInForce::Fok,
513 _ => BinanceTimeInForce::Unknown,
514 }
515}
516
517fn map_order_type(ot: order_type::OrderType) -> &'static str {
518 match ot {
519 order_type::OrderType::Market => "MARKET",
520 order_type::OrderType::Limit => "LIMIT",
521 order_type::OrderType::StopLoss => "STOP_LOSS",
522 order_type::OrderType::StopLossLimit => "STOP_LOSS_LIMIT",
523 order_type::OrderType::TakeProfit => "TAKE_PROFIT",
524 order_type::OrderType::TakeProfitLimit => "TAKE_PROFIT_LIMIT",
525 order_type::OrderType::LimitMaker => "LIMIT_MAKER",
526 _ => "UNKNOWN",
527 }
528}
529
530fn map_expiry_reason(reason: expiry_reason::ExpiryReason) -> Option<String> {
531 match reason {
532 expiry_reason::ExpiryReason::Rejected => Some("REJECTED".to_string()),
533 expiry_reason::ExpiryReason::ExchangeCanceled => Some("EXCHANGE_CANCELED".to_string()),
534 expiry_reason::ExpiryReason::OcoTrigger => Some("OCO_TRIGGER".to_string()),
535 expiry_reason::ExpiryReason::OtoPhaseOneExpired => {
536 Some("OTO_PHASE_ONE_EXPIRED".to_string())
537 }
538 expiry_reason::ExpiryReason::UnfilledIocQuantityExpired => {
539 Some("UNFILLED_IOC_QUANTITY_EXPIRED".to_string())
540 }
541 expiry_reason::ExpiryReason::UnfilledFokOrderExpired => {
542 Some("UNFILLED_FOK_ORDER_EXPIRED".to_string())
543 }
544 expiry_reason::ExpiryReason::InsufficientLiquidity => {
545 Some("INSUFFICIENT_LIQUIDITY".to_string())
546 }
547 expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded => {
548 Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED".to_string())
549 }
550 expiry_reason::ExpiryReason::NonRepresentable => Some("NON_REPRESENTABLE".to_string()),
551 expiry_reason::ExpiryReason::NullVal => None,
552 }
553}
554
555#[inline]
557fn us_to_ms(us: i64) -> i64 {
558 if us < 0 { us } else { us / 1_000 }
559}
560
561fn mantissa_to_decimal(mantissa: i64, exponent: i8) -> Decimal {
563 if exponent >= 0 {
564 Decimal::from(mantissa) * Decimal::from(10_i64.pow(exponent as u32))
565 } else {
566 Decimal::new(mantissa, (-exponent) as u32)
567 }
568}
569
570fn mantissa_to_decimal_string(mantissa: i64, exponent: i8) -> String {
575 if mantissa == 0 {
576 if exponent >= 0 {
577 return "0".to_string();
578 }
579 let mut s = "0.".to_string();
580 for _ in 0..(-exponent) {
581 s.push('0');
582 }
583 return s;
584 }
585
586 let negative = mantissa < 0;
587 let abs_mantissa = mantissa.unsigned_abs();
588 let digits = abs_mantissa.to_string();
589
590 let result = if exponent >= 0 {
591 let mut s = digits;
592 for _ in 0..exponent {
593 s.push('0');
594 }
595 s
596 } else {
597 let decimal_places = (-exponent) as usize;
598 if digits.len() <= decimal_places {
599 let padding = decimal_places - digits.len();
600 let mut s = "0.".to_string();
601 for _ in 0..padding {
602 s.push('0');
603 }
604 s.push_str(&digits);
605 s
606 } else {
607 let split_pos = digits.len() - decimal_places;
608 let mut s = digits[..split_pos].to_string();
609 s.push('.');
610 s.push_str(&digits[split_pos..]);
611 s
612 }
613 };
614
615 if negative {
616 format!("-{result}")
617 } else {
618 result
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use rstest::rstest;
625
626 use super::*;
627 use crate::spot::sbe::spot::{
628 WriteBuf, bool_enum::BoolEnum, execution_type::ExecutionType, floor, match_type,
629 order_capacity, order_side::OrderSide, order_status::OrderStatus,
630 order_type::OrderType as SbeOrderType, peg_offset_type, peg_price_type,
631 self_trade_prevention_mode::SelfTradePreventionMode, time_in_force::TimeInForce as SbeTif,
632 };
633
634 #[expect(clippy::too_many_arguments)]
635 fn encode_execution_report(
636 symbol: &str,
637 client_order_id: &str,
638 order_id: i64,
639 trade_id: Option<i64>,
640 side: OrderSide,
641 order_type: SbeOrderType,
642 tif: SbeTif,
643 exec_type: ExecutionType,
644 status: OrderStatus,
645 price_exp: i8,
646 qty_exp: i8,
647 commission_exp: i8,
648 price_mantissa: i64,
649 orig_qty_mantissa: i64,
650 stop_price_mantissa: i64,
651 last_qty_mantissa: i64,
652 last_price_mantissa: i64,
653 executed_qty_mantissa: i64,
654 cumm_quote_qty_mantissa: i64,
655 commission_mantissa: i64,
656 commission_asset: &str,
657 is_maker: bool,
658 is_working: bool,
659 event_time_us: i64,
660 transact_time_us: i64,
661 order_creation_time_us: Option<i64>,
662 expiry_reason: expiry_reason::ExpiryReason,
663 ) -> Vec<u8> {
664 let var_data_len = 6 + symbol.len() + client_order_id.len() + commission_asset.len();
665 let total = 8 + execution_report_event_codec::SBE_BLOCK_LENGTH as usize + var_data_len;
666 let mut buf_vec = vec![0u8; total];
667
668 let buf = WriteBuf::new(buf_vec.as_mut_slice());
669 let enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
670 .wrap(buf, HEADER_LEN);
671 let mut header = enc.header(0);
672 let mut enc = header.parent().unwrap();
673
674 enc.event_time(event_time_us);
675 enc.transact_time(transact_time_us);
676 enc.price_exponent(price_exp);
677 enc.qty_exponent(qty_exp);
678 enc.commission_exponent(commission_exp);
679 enc.order_creation_time(order_creation_time_us.unwrap_or(i64::MIN));
680 enc.working_time(i64::MIN); enc.order_id(order_id);
682 enc.order_list_id(i64::MIN); enc.orig_qty(orig_qty_mantissa);
684 enc.price(price_mantissa);
685 enc.orig_quote_order_qty(0);
686 enc.iceberg_qty(0);
687 enc.stop_price(stop_price_mantissa);
688 enc.order_type(order_type);
689 enc.side(side);
690 enc.time_in_force(tif);
691 enc.execution_type(exec_type);
692 enc.order_status(status);
693 enc.trade_id(trade_id.unwrap_or(i64::MIN));
694 enc.execution_id(0);
695 enc.executed_qty(executed_qty_mantissa);
696 enc.cummulative_quote_qty(cumm_quote_qty_mantissa);
697 enc.last_qty(last_qty_mantissa);
698 enc.last_price(last_price_mantissa);
699 enc.quote_qty(0);
700 enc.commission(commission_mantissa);
701 enc.is_working(if is_working {
702 BoolEnum::True
703 } else {
704 BoolEnum::False
705 });
706 enc.is_maker(if is_maker {
707 BoolEnum::True
708 } else {
709 BoolEnum::False
710 });
711 enc.is_best_match(BoolEnum::False);
712 enc.match_type(match_type::MatchType::default());
713 enc.self_trade_prevention_mode(SelfTradePreventionMode::default());
714 enc.order_capacity(order_capacity::OrderCapacity::default());
715 enc.working_floor(floor::Floor::default());
716 enc.used_sor(BoolEnum::False);
717 enc.alloc_id(i64::MIN);
718 enc.trailing_delta(u64::MAX);
719 enc.trailing_time(i64::MIN);
720 enc.trade_group_id(i64::MIN);
721 enc.prevented_qty(0);
722 enc.last_prevented_qty(i64::MIN);
723 enc.prevented_match_id(i64::MIN);
724 enc.prevented_execution_qty(i64::MIN);
725 enc.prevented_execution_price(i64::MIN);
726 enc.prevented_execution_quote_qty(i64::MIN);
727 enc.strategy_type(i32::MIN);
728 enc.strategy_id(i64::MIN);
729 enc.counter_order_id(i64::MIN);
730 enc.subscription_id(0xFFFF); enc.peg_price_type(peg_price_type::PegPriceType::default());
732 enc.peg_offset_type(peg_offset_type::PegOffsetType::default());
733 enc.peg_offset_value(0xFF); enc.pegged_price(i64::MIN);
735 enc.expiry_reason(expiry_reason);
736
737 enc.symbol(symbol);
739 enc.client_order_id(client_order_id);
740 enc.orig_client_order_id("");
741 enc.commission_asset(commission_asset);
742 enc.reject_reason("");
743 enc.counter_symbol("");
744
745 buf_vec
746 }
747
748 fn encode_bounds_execution_report() -> Vec<u8> {
749 encode_execution_report(
750 "SYMBOL1",
751 "CLIENT2",
752 12345678,
753 Some(87654321),
754 OrderSide::Buy,
755 SbeOrderType::Limit,
756 SbeTif::Gtc,
757 ExecutionType::Trade,
758 OrderStatus::Filled,
759 -2,
760 -5,
761 -8,
762 123456,
763 234567,
764 345678,
765 456789,
766 567891,
767 678912,
768 789123,
769 891234,
770 "ASSET3",
771 true,
772 false,
773 1709654400123456,
774 1709654400234567,
775 Some(1709654400345678),
776 expiry_reason::ExpiryReason::NullVal,
777 )
778 }
779
780 fn encode_account_position(
781 event_time_us: i64,
782 update_time_us: i64,
783 balances: &[(&str, i8, i64, i64)], ) -> Vec<u8> {
785 let var_data_len: usize = balances.iter().map(|(a, _, _, _)| 1 + a.len()).sum();
786 let total = 8 + 18 + 6 + (balances.len() * 17) + var_data_len;
787 let mut buf_vec = vec![0u8; total];
788
789 let buf = WriteBuf::new(buf_vec.as_mut_slice());
790 let enc =
791 outbound_account_position_event_codec::OutboundAccountPositionEventEncoder::default()
792 .wrap(buf, HEADER_LEN);
793 let mut header = enc.header(0);
794 let mut enc = header.parent().unwrap();
795
796 enc.event_time(event_time_us);
797 enc.update_time(update_time_us);
798 enc.subscription_id(0xFFFF); let balances_enc =
801 outbound_account_position_event_codec::encoder::BalancesEncoder::default();
802 let mut bal_enc = enc.balances_encoder(balances.len() as u32, balances_enc);
803
804 for (asset, exponent, free, locked) in balances {
805 bal_enc.advance().unwrap();
806 bal_enc.exponent(*exponent);
807 bal_enc.free(*free);
808 bal_enc.locked(*locked);
809 bal_enc.asset(asset);
810 }
811
812 buf_vec
813 }
814
815 #[rstest]
816 fn test_mantissa_to_decimal_string_basic() {
817 assert_eq!(mantissa_to_decimal_string(250000, -2), "2500.00");
818 assert_eq!(mantissa_to_decimal_string(100000000, -8), "1.00000000");
819 assert_eq!(mantissa_to_decimal_string(0, -8), "0.00000000");
820 assert_eq!(mantissa_to_decimal_string(0, 0), "0");
821 assert_eq!(mantissa_to_decimal_string(42, 0), "42");
822 assert_eq!(mantissa_to_decimal_string(42, 2), "4200");
823 assert_eq!(mantissa_to_decimal_string(5, -3), "0.005");
824 assert_eq!(mantissa_to_decimal_string(-250000, -2), "-2500.00");
825 }
826
827 #[rstest]
828 #[case::typical_price(250000_i64, -2_i8, "2500.00")]
829 #[case::btc_one(100000000_i64, -8_i8, "1.00000000")]
830 #[case::zero_negative_exp(0_i64, -8_i8, "0")]
831 #[case::zero_zero_exp(0_i64, 0_i8, "0")]
832 #[case::whole_no_scale(42_i64, 0_i8, "42")]
833 #[case::positive_exponent(42_i64, 2_i8, "4200")]
834 #[case::small_fractional(5_i64, -3_i8, "0.005")]
835 #[case::negative_mantissa(-250000_i64, -2_i8, "-2500.00")]
836 #[case::large_positive_exponent(1_i64, 9_i8, "1000000000")]
837 fn test_mantissa_to_decimal_parametrized(
838 #[case] mantissa: i64,
839 #[case] exponent: i8,
840 #[case] expected: &str,
841 ) {
842 let result = mantissa_to_decimal(mantissa, exponent);
843 assert_eq!(result, Decimal::from_str_exact(expected).unwrap());
844 }
845
846 #[rstest]
847 #[case::rejected(expiry_reason::ExpiryReason::Rejected, Some("REJECTED"))]
848 #[case::exchange_canceled(
849 expiry_reason::ExpiryReason::ExchangeCanceled,
850 Some("EXCHANGE_CANCELED")
851 )]
852 #[case::oco_trigger(expiry_reason::ExpiryReason::OcoTrigger, Some("OCO_TRIGGER"))]
853 #[case::oto_phase_one_expired(
854 expiry_reason::ExpiryReason::OtoPhaseOneExpired,
855 Some("OTO_PHASE_ONE_EXPIRED")
856 )]
857 #[case::unfilled_ioc_quantity_expired(
858 expiry_reason::ExpiryReason::UnfilledIocQuantityExpired,
859 Some("UNFILLED_IOC_QUANTITY_EXPIRED")
860 )]
861 #[case::unfilled_fok_order_expired(
862 expiry_reason::ExpiryReason::UnfilledFokOrderExpired,
863 Some("UNFILLED_FOK_ORDER_EXPIRED")
864 )]
865 #[case::insufficient_liquidity(
866 expiry_reason::ExpiryReason::InsufficientLiquidity,
867 Some("INSUFFICIENT_LIQUIDITY")
868 )]
869 #[case::execution_rule_price_range_exceeded(
870 expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded,
871 Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED")
872 )]
873 #[case::non_representable(
874 expiry_reason::ExpiryReason::NonRepresentable,
875 Some("NON_REPRESENTABLE")
876 )]
877 #[case::null_val(expiry_reason::ExpiryReason::NullVal, None)]
878 fn test_map_expiry_reason(
879 #[case] reason: expiry_reason::ExpiryReason,
880 #[case] expected: Option<&str>,
881 ) {
882 let result = map_expiry_reason(reason);
883
884 assert_eq!(result.as_deref(), expected);
885 }
886
887 #[rstest]
888 fn test_decode_execution_report_new_limit() {
889 let data = encode_execution_report(
890 "ETHUSDT",
891 "O-20200101-000000-000-000-0",
892 12345678,
893 None, OrderSide::Buy,
895 SbeOrderType::Limit,
896 SbeTif::Gtc,
897 ExecutionType::New,
898 OrderStatus::New,
899 -2, -5, -8, 250000, 100000, 0, 0, 0, 0, 0, 0, "", false,
912 true, 1709654400000000, 1709654400000000, Some(1709654400000000),
916 expiry_reason::ExpiryReason::NullVal,
917 );
918
919 let report = decode_execution_report(&data).unwrap();
920
921 assert_eq!(report.symbol, "ETHUSDT");
922 assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
923 assert_eq!(report.order_id, 12345678);
924 assert_eq!(report.side, BinanceSide::Buy);
925 assert_eq!(report.order_type, "LIMIT");
926 assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
927 assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
928 assert_eq!(report.order_status, BinanceOrderStatus::New);
929 assert_eq!(report.price, "2500.00");
930 assert_eq!(report.original_qty, "1.00000");
931 assert_eq!(report.trade_id, -1);
932 assert!(report.is_working);
933 assert!(!report.is_maker);
934 assert_eq!(report.event_time, 1709654400000);
935 assert_eq!(report.transaction_time, 1709654400000);
936 assert!(report.expiry_reason.is_none());
937 }
938
939 #[rstest]
940 fn test_decode_execution_report_expiry_reason() {
941 let data = encode_execution_report(
942 "ETHUSDT",
943 "O-20200101-000000-000-000-0",
944 12345678,
945 None,
946 OrderSide::Buy,
947 SbeOrderType::Limit,
948 SbeTif::Ioc,
949 ExecutionType::Expired,
950 OrderStatus::Expired,
951 -2,
952 -5,
953 -8,
954 250000,
955 100000,
956 0,
957 0,
958 0,
959 0,
960 0,
961 0,
962 "",
963 false,
964 false,
965 1709654400000000,
966 1709654400000000,
967 Some(1709654400000000),
968 expiry_reason::ExpiryReason::InsufficientLiquidity,
969 );
970
971 let report = decode_execution_report(&data).unwrap();
972
973 assert_eq!(report.execution_type, BinanceSpotExecutionType::Expired);
974 assert_eq!(report.order_status, BinanceOrderStatus::Expired);
975 assert_eq!(
976 report.expiry_reason.as_deref(),
977 Some("INSUFFICIENT_LIQUIDITY")
978 );
979 }
980
981 #[rstest]
982 fn test_decode_execution_report_trade_fill() {
983 let data = encode_execution_report(
984 "ETHUSDT",
985 "O-20200101-000000-000-000-0",
986 12345678,
987 Some(98765432),
988 OrderSide::Buy,
989 SbeOrderType::Limit,
990 SbeTif::Gtc,
991 ExecutionType::Trade,
992 OrderStatus::Filled,
993 -2, -8, -8, 250000, 100000000, 0, 100000000, 250000, 100000000, 250000000000, 250000, "USDT",
1005 true, false,
1007 1709654400000000,
1008 1709654400000000,
1009 Some(1709654400000000),
1010 expiry_reason::ExpiryReason::NullVal,
1011 );
1012
1013 let report = decode_execution_report(&data).unwrap();
1014
1015 assert_eq!(report.execution_type, BinanceSpotExecutionType::Trade);
1016 assert_eq!(report.order_status, BinanceOrderStatus::Filled);
1017 assert_eq!(report.trade_id, 98765432);
1018 assert_eq!(report.last_filled_qty, "1.00000000");
1019 assert_eq!(report.last_filled_price, "2500.00");
1020 assert_eq!(report.commission_asset, Some(Ustr::from("USDT")));
1021 assert!(report.is_maker);
1022 }
1023
1024 #[rstest]
1025 fn test_decode_execution_report_canceled() {
1026 let data = encode_execution_report(
1027 "BTCUSDT",
1028 "O-20200101-000000-000-000-1",
1029 99999,
1030 None,
1031 OrderSide::Sell,
1032 SbeOrderType::Limit,
1033 SbeTif::Gtc,
1034 ExecutionType::Canceled,
1035 OrderStatus::Canceled,
1036 -2,
1037 -8,
1038 -8,
1039 5000000, 10000000, 0,
1042 0,
1043 0,
1044 0,
1045 0,
1046 0,
1047 "",
1048 false,
1049 false,
1050 1709654400000000,
1051 1709654400000000,
1052 Some(1709654400000000),
1053 expiry_reason::ExpiryReason::NullVal,
1054 );
1055
1056 let report = decode_execution_report(&data).unwrap();
1057
1058 assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1059 assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1060 assert_eq!(report.symbol, "BTCUSDT");
1061 assert_eq!(report.side, BinanceSide::Sell);
1062 }
1063
1064 #[rstest]
1065 fn test_decode_execution_report_stop_loss_limit() {
1066 let data = encode_execution_report(
1067 "ETHUSDT",
1068 "O-20200101-000000-000-000-1",
1069 12345679,
1070 None,
1071 OrderSide::Sell,
1072 SbeOrderType::StopLossLimit,
1073 SbeTif::Gtc,
1074 ExecutionType::New,
1075 OrderStatus::New,
1076 -2,
1077 -5,
1078 -8,
1079 240000, 100000, 245000, 0,
1083 0,
1084 0,
1085 0,
1086 0,
1087 "",
1088 false,
1089 true,
1090 1709654400000000,
1091 1709654400000000,
1092 Some(1709654400000000),
1093 expiry_reason::ExpiryReason::NullVal,
1094 );
1095
1096 let report = decode_execution_report(&data).unwrap();
1097
1098 assert_eq!(report.order_type, "STOP_LOSS_LIMIT");
1099 assert_eq!(report.price, "2400.00");
1100 assert_eq!(report.stop_price, "2450.00");
1101 }
1102
1103 #[rstest]
1104 fn test_decode_execution_report_truncated_header() {
1105 let data = vec![0u8; 5];
1106 let err = decode_execution_report(&data).unwrap_err();
1107 assert!(err.to_string().contains("too short for SBE header"));
1108 }
1109
1110 #[rstest]
1111 fn test_decode_execution_report_rejects_truncated_variable_data() {
1112 let data = encode_bounds_execution_report();
1113 let lengths = ["SYMBOL1".len(), "CLIENT2".len(), 0, "ASSET3".len(), 0, 0];
1114 let mut offset = HEADER_LEN + usize::from(execution_report_event_codec::SBE_BLOCK_LENGTH);
1115
1116 for (field, length) in EXECUTION_REPORT_VAR_DATA_FIELDS.into_iter().zip(lengths) {
1117 let err = decode_execution_report(&data[..offset]).unwrap_err();
1118 assert_eq!(
1119 err.to_string(),
1120 format!(
1121 "Buffer too short for {field} length: expected {}, was {offset}",
1122 offset + 1
1123 )
1124 );
1125
1126 if length > 0 {
1127 let truncated_len = offset + length;
1128 let expected_len = offset + 1 + length;
1129 let err = decode_execution_report(&data[..truncated_len]).unwrap_err();
1130 assert_eq!(
1131 err.to_string(),
1132 format!(
1133 "Buffer too short for {field}: expected {expected_len}, was {truncated_len}"
1134 )
1135 );
1136 }
1137
1138 offset += 1 + length;
1139 }
1140 }
1141
1142 #[rstest]
1143 fn test_decode_execution_report_rejects_short_declared_block() {
1144 let mut data = encode_bounds_execution_report();
1145 let block_length = execution_report_event_codec::SBE_BLOCK_LENGTH - 1;
1146 data[..2].copy_from_slice(&block_length.to_le_bytes());
1147
1148 let err = decode_execution_report(&data).unwrap_err();
1149
1150 assert_eq!(
1151 err.to_string(),
1152 format!(
1153 "SBE execution report block length too short: expected at least {EXECUTION_REPORT_BLOCK_LENGTH_V3}, was {block_length}"
1154 )
1155 );
1156 }
1157
1158 #[rstest]
1159 fn test_decode_execution_report_rejects_short_version_2_block() {
1160 let mut data = crate::common::testing::load_fixture_bytes(
1161 "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1162 );
1163 let block_length = EXECUTION_REPORT_BLOCK_LENGTH_V1 as u16 - 1;
1164 data[..2].copy_from_slice(&block_length.to_le_bytes());
1165
1166 let err = decode_execution_report(&data).unwrap_err();
1167
1168 assert_eq!(
1169 err.to_string(),
1170 format!(
1171 "SBE execution report block length too short: expected at least {EXECUTION_REPORT_BLOCK_LENGTH_V1}, was {block_length}"
1172 )
1173 );
1174 }
1175
1176 #[rstest]
1177 fn test_decode_execution_report_wrong_template() {
1178 let mut data = encode_execution_report(
1179 "TEST",
1180 "test",
1181 1,
1182 None,
1183 OrderSide::Buy,
1184 SbeOrderType::Limit,
1185 SbeTif::Gtc,
1186 ExecutionType::New,
1187 OrderStatus::New,
1188 -2,
1189 -8,
1190 -8,
1191 0,
1192 0,
1193 0,
1194 0,
1195 0,
1196 0,
1197 0,
1198 0,
1199 "",
1200 false,
1201 false,
1202 0,
1203 0,
1204 None,
1205 expiry_reason::ExpiryReason::NullVal,
1206 );
1207 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1209
1210 let err = decode_execution_report(&data).unwrap_err();
1211 assert!(err.to_string().contains("Wrong template ID"));
1212 }
1213
1214 #[rstest]
1215 fn test_decode_account_position_single_balance() {
1216 let data = encode_account_position(
1217 1709654400000000, 1709654400000000, &[("USDT", -8, 1000000000000, 50000000000)], );
1221
1222 let msg = decode_account_position(&data).unwrap();
1223
1224 assert_eq!(msg.event_type, "outboundAccountPosition");
1225 assert_eq!(msg.event_time, 1709654400000);
1226 assert_eq!(msg.balances.len(), 1);
1227 assert_eq!(msg.balances[0].asset, "USDT");
1228 assert_eq!(
1229 msg.balances[0].free,
1230 Decimal::from_str_exact("10000.00000000").unwrap()
1231 );
1232 assert_eq!(
1233 msg.balances[0].locked,
1234 Decimal::from_str_exact("500.00000000").unwrap()
1235 );
1236 }
1237
1238 #[rstest]
1239 fn test_decode_account_position_multiple_balances() {
1240 let data = encode_account_position(
1241 1709654400000000,
1242 1709654400000000,
1243 &[
1244 ("BTC", -8, 100000000, 0), ("USDT", -8, 5000000000000, 0), ],
1247 );
1248
1249 let msg = decode_account_position(&data).unwrap();
1250
1251 assert_eq!(msg.balances.len(), 2);
1252 assert_eq!(msg.balances[0].asset, "BTC");
1253 assert_eq!(
1254 msg.balances[0].free,
1255 Decimal::from_str_exact("1.00000000").unwrap()
1256 );
1257 assert_eq!(msg.balances[1].asset, "USDT");
1258 assert_eq!(
1259 msg.balances[1].free,
1260 Decimal::from_str_exact("50000.00000000").unwrap()
1261 );
1262 }
1263
1264 #[rstest]
1265 fn test_decode_account_position_zero_balances() {
1266 let data = encode_account_position(1709654400000000, 1709654400000000, &[]);
1267
1268 let msg = decode_account_position(&data).unwrap();
1269 assert!(msg.balances.is_empty());
1270 }
1271
1272 #[rstest]
1273 fn test_decode_account_position_truncated_header() {
1274 let data = vec![0u8; 5];
1275 let err = decode_account_position(&data).unwrap_err();
1276 assert!(err.to_string().contains("too short for SBE header"));
1277 }
1278
1279 #[rstest]
1280 fn test_decode_account_position_wrong_template() {
1281 let mut data = encode_account_position(0, 0, &[]);
1282 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1283
1284 let err = decode_account_position(&data).unwrap_err();
1285 assert!(err.to_string().contains("Wrong template ID"));
1286 }
1287
1288 #[rstest]
1289 #[case::zero_v0(0, 0)]
1290 #[case::short_v0(0, ACCOUNT_POSITION_BLOCK_LENGTH_V0 as u16 - 1)]
1291 #[case::zero_v1(1, 0)]
1292 #[case::v0_width_in_v1(1, ACCOUNT_POSITION_BLOCK_LENGTH_V0 as u16)]
1293 #[case::short_v1(1, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1294 #[case::short_current(5, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1295 #[case::short_future(6, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1296 fn test_decode_account_position_rejects_short_declared_block(
1297 #[case] version: u16,
1298 #[case] block_length: u16,
1299 ) {
1300 let mut data = encode_account_position(123000, 456000, &[("BTC", -2, 12345, 6789)]);
1301 data[..2].copy_from_slice(&block_length.to_le_bytes());
1302 data[6..8].copy_from_slice(&version.to_le_bytes());
1303
1304 let min_block_len = if version == 0 {
1305 ACCOUNT_POSITION_BLOCK_LENGTH_V0
1306 } else {
1307 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
1308 };
1309
1310 let err = decode_account_position(&data).unwrap_err();
1311
1312 assert_eq!(
1313 err.to_string(),
1314 format!(
1315 "SBE account position block length too short: expected at least {min_block_len}, was {block_length}"
1316 )
1317 );
1318 }
1319
1320 #[rstest]
1321 fn test_decode_account_position_rejects_truncated_framing() {
1322 let data = encode_account_position(
1323 123000,
1324 456000,
1325 &[("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)],
1326 );
1327
1328 for length in 0..data.len() {
1329 assert!(
1330 decode_account_position(&data[..length]).is_err(),
1331 "Accepted truncated account position of length {length}"
1332 );
1333 }
1334 }
1335
1336 #[rstest]
1337 #[case::zero_empty(0, 0)]
1338 #[case::zero_nonempty(0, 1)]
1339 #[case::short_empty(ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH as u16 - 1, 0)]
1340 #[case::short_nonempty(ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH as u16 - 1, 1)]
1341 fn test_decode_account_position_rejects_short_entry_block(
1342 #[case] block_length: u16,
1343 #[case] count: u32,
1344 ) {
1345 let mut data = encode_account_position(123000, 456000, &[("BTC", -2, 12345, 6789)]);
1346 let group_offset =
1347 HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
1348 data[group_offset..group_offset + 2].copy_from_slice(&block_length.to_le_bytes());
1349 data[group_offset + 2..group_offset + group_size_encoding_codec::ENCODED_LENGTH]
1350 .copy_from_slice(&count.to_le_bytes());
1351
1352 let err = decode_account_position(&data).unwrap_err();
1353
1354 assert_eq!(
1355 err.to_string(),
1356 format!(
1357 "SBE balances entry block length too short: expected at least {ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH}, was {block_length}"
1358 )
1359 );
1360 }
1361
1362 #[rstest]
1363 #[case::first(0)]
1364 #[case::second(1)]
1365 fn test_decode_account_position_rejects_oversized_asset(#[case] index: usize) {
1366 let mut data = encode_account_position(
1367 123000,
1368 456000,
1369 &[("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)],
1370 );
1371 let mut asset_offset = HEADER_LEN
1372 + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
1373 + group_size_encoding_codec::ENCODED_LENGTH
1374 + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
1375
1376 if index == 1 {
1377 asset_offset += 1 + "BTC".len() + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
1378 }
1379
1380 data[asset_offset] = u8::MAX;
1381
1382 let err = decode_account_position(&data).unwrap_err();
1383
1384 assert_eq!(
1385 err.to_string(),
1386 format!(
1387 "Buffer too short for asset: expected {}, was {}",
1388 asset_offset + 1 + usize::from(u8::MAX),
1389 data.len()
1390 )
1391 );
1392 }
1393
1394 #[rstest]
1395 #[case::v0(
1396 0,
1397 ACCOUNT_POSITION_BLOCK_LENGTH_V0,
1398 ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
1399 )]
1400 #[case::v1(
1401 1,
1402 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH),
1403 ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
1404 )]
1405 #[case::current(
1406 5,
1407 usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH),
1408 ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
1409 )]
1410 #[case::fixed_extension(5, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH) + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH)]
1411 #[case::entry_extension(5, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH), ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
1412 #[case::v0_extensions(0, ACCOUNT_POSITION_BLOCK_LENGTH_V0 + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
1413 #[case::future_extensions(6, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH) + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
1414 fn test_decode_account_position_accepts_versioned_blocks(
1415 #[case] version: u16,
1416 #[case] block_length: usize,
1417 #[case] entry_block_length: usize,
1418 ) {
1419 let balances = [("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)];
1420 let original = encode_account_position(123000, 456000, &balances);
1421 let group_offset =
1422 HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
1423 let mut data = original[..HEADER_LEN + ACCOUNT_POSITION_BLOCK_LENGTH_V0].to_vec();
1424 data.resize(HEADER_LEN + block_length, 0xA5);
1425 data[..2].copy_from_slice(&(block_length as u16).to_le_bytes());
1426 data[6..8].copy_from_slice(&version.to_le_bytes());
1427 data.extend_from_slice(
1428 &original[group_offset..group_offset + group_size_encoding_codec::ENCODED_LENGTH],
1429 );
1430 data[HEADER_LEN + block_length..HEADER_LEN + block_length + 2]
1431 .copy_from_slice(&(entry_block_length as u16).to_le_bytes());
1432 let mut entry_offset = group_offset + group_size_encoding_codec::ENCODED_LENGTH;
1433
1434 for (asset, _, _, _) in balances {
1435 data.extend_from_slice(
1436 &original[entry_offset..entry_offset + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH],
1437 );
1438 data.resize(
1439 data.len() + entry_block_length - ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH,
1440 0xA5,
1441 );
1442 let asset_offset = entry_offset + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
1443 entry_offset = asset_offset + 1 + asset.len();
1444 data.extend_from_slice(&original[asset_offset..entry_offset]);
1445 }
1446
1447 let msg = decode_account_position(&data).unwrap();
1448
1449 assert_eq!(msg.event_type, "outboundAccountPosition");
1450 assert_eq!(msg.event_time, 123);
1451 assert_eq!(msg.last_update_time, 456);
1452 assert_eq!(msg.balances.len(), balances.len());
1453
1454 for (balance, (asset, exponent, free, locked)) in msg.balances.iter().zip(balances) {
1455 assert_eq!(balance.asset, asset);
1456 assert_eq!(balance.free, Decimal::new(free, (-exponent) as u32));
1457 assert_eq!(balance.locked, Decimal::new(locked, (-exponent) as u32));
1458 }
1459 }
1460
1461 fn encode_balance_update(
1462 event_time_us: i64,
1463 clear_time_us: i64,
1464 qty_exponent: i8,
1465 free_qty_delta: i64,
1466 asset: &str,
1467 ) -> Vec<u8> {
1468 let total = 8 + 27 + 1 + asset.len();
1469 let mut buf_vec = vec![0u8; total];
1470
1471 let buf = WriteBuf::new(buf_vec.as_mut_slice());
1472 let enc =
1473 balance_update_event_codec::BalanceUpdateEventEncoder::default().wrap(buf, HEADER_LEN);
1474 let mut header = enc.header(0);
1475 let mut enc = header.parent().unwrap();
1476
1477 enc.event_time(event_time_us);
1478 enc.clear_time(clear_time_us);
1479 enc.qty_exponent(qty_exponent);
1480 enc.free_qty_delta(free_qty_delta);
1481 enc.subscription_id(0xFFFF); enc.asset(asset);
1483
1484 buf_vec
1485 }
1486
1487 #[rstest]
1488 fn test_decode_balance_update() {
1489 let data = encode_balance_update(
1490 1709654400000000, 1709654400000000, -8,
1493 10000000000, "BTC",
1495 );
1496
1497 let msg = decode_balance_update(&data).unwrap();
1498
1499 assert_eq!(msg.event_type, "balanceUpdate");
1500 assert_eq!(msg.event_time, 1709654400000);
1501 assert_eq!(msg.asset, "BTC");
1502 assert_eq!(msg.delta, "100.00000000");
1503 assert_eq!(msg.clear_time, 1709654400000);
1504 }
1505
1506 #[rstest]
1507 fn test_decode_balance_update_truncated_header() {
1508 let data = vec![0u8; 5];
1509 let err = decode_balance_update(&data).unwrap_err();
1510 assert!(err.to_string().contains("too short for SBE header"));
1511 }
1512
1513 #[rstest]
1514 fn test_decode_balance_update_wrong_template() {
1515 let mut data = encode_balance_update(0, 0, -8, 0, "BTC");
1516 data[2..4].copy_from_slice(&50u16.to_le_bytes());
1517
1518 let err = decode_balance_update(&data).unwrap_err();
1519 assert!(err.to_string().contains("Wrong template ID"));
1520 }
1521
1522 #[rstest]
1523 #[case::zero_v0(0, 0)]
1524 #[case::short_v0(0, BALANCE_UPDATE_BLOCK_LENGTH_V0 as u16 - 1)]
1525 #[case::zero_v1(1, 0)]
1526 #[case::v0_width_in_v1(1, BALANCE_UPDATE_BLOCK_LENGTH_V0 as u16)]
1527 #[case::short_v1(1, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
1528 #[case::short_current(5, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
1529 #[case::short_future(6, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
1530 fn test_decode_balance_update_rejects_short_declared_block(
1531 #[case] version: u16,
1532 #[case] block_length: u16,
1533 ) {
1534 let mut data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1535 data[..2].copy_from_slice(&block_length.to_le_bytes());
1536 data[6..8].copy_from_slice(&version.to_le_bytes());
1537
1538 let min_block_len = if version == 0 {
1539 BALANCE_UPDATE_BLOCK_LENGTH_V0
1540 } else {
1541 usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH)
1542 };
1543
1544 let err = decode_balance_update(&data).unwrap_err();
1545
1546 assert_eq!(
1547 err.to_string(),
1548 format!(
1549 "SBE balance update block length too short: expected at least {min_block_len}, was {block_length}"
1550 )
1551 );
1552 }
1553
1554 #[rstest]
1555 fn test_decode_balance_update_rejects_truncated_framing() {
1556 let data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1557
1558 for length in 0..data.len() {
1559 assert!(
1560 decode_balance_update(&data[..length]).is_err(),
1561 "Accepted length {length}"
1562 );
1563 }
1564 }
1565
1566 #[rstest]
1567 fn test_decode_balance_update_rejects_oversized_asset() {
1568 let mut data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1569 let asset_offset = HEADER_LEN + usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH);
1570 data[asset_offset] = u8::MAX;
1571
1572 let err = decode_balance_update(&data).unwrap_err();
1573
1574 assert_eq!(
1575 err.to_string(),
1576 format!(
1577 "Buffer too short for asset: expected {}, was {}",
1578 asset_offset + 1 + usize::from(u8::MAX),
1579 data.len()
1580 )
1581 );
1582 }
1583
1584 #[rstest]
1585 #[case::v0(0, BALANCE_UPDATE_BLOCK_LENGTH_V0)]
1586 #[case::v1(1, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH))]
1587 #[case::current(5, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH))]
1588 #[case::v0_extension(0, BALANCE_UPDATE_BLOCK_LENGTH_V0 + 4)]
1589 #[case::current_extension(5, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH) + 4)]
1590 #[case::future_extension(6, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH) + 4)]
1591 fn test_decode_balance_update_accepts_versioned_blocks(
1592 #[case] version: u16,
1593 #[case] block_length: usize,
1594 ) {
1595 let original = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1596 let asset_offset = HEADER_LEN + usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH);
1597 let mut data = original[..HEADER_LEN + BALANCE_UPDATE_BLOCK_LENGTH_V0].to_vec();
1598 data.resize(HEADER_LEN + block_length, 0xA5);
1599 data[..2].copy_from_slice(&(block_length as u16).to_le_bytes());
1600 data[6..8].copy_from_slice(&version.to_le_bytes());
1601 data.extend_from_slice(&original[asset_offset..]);
1602
1603 let msg = decode_balance_update(&data).unwrap();
1604
1605 assert_eq!(msg.event_type, "balanceUpdate");
1606 assert_eq!(msg.event_time, 123);
1607 assert_eq!(msg.clear_time, 456);
1608 assert_eq!(msg.asset, "BTC");
1609 assert_eq!(msg.delta, "123.45");
1610 }
1611
1612 #[rstest]
1613 fn test_us_to_ms() {
1614 assert_eq!(us_to_ms(1_709_654_400_000_000), 1_709_654_400_000);
1615 assert_eq!(us_to_ms(1_709_654_400_123_456), 1_709_654_400_123);
1616 }
1617
1618 #[rstest]
1619 fn test_us_to_ms_preserves_negative_submillisecond_value() {
1620 assert_eq!(us_to_ms(-1), -1);
1621 }
1622
1623 #[rstest]
1624 fn test_decode_captured_execution_report_new() {
1625 let data = crate::common::testing::load_fixture_bytes(
1626 "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1627 );
1628 let report = decode_execution_report(&data).unwrap();
1629
1630 assert_eq!(report.symbol, "BTCUSDT");
1631 assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
1632 assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
1633 assert_eq!(report.order_status, BinanceOrderStatus::New);
1634 assert_eq!(report.side, BinanceSide::Buy);
1635 assert_eq!(report.order_type, "LIMIT");
1636 assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
1637 assert_eq!(report.order_id, 12345678);
1638 assert!(report.is_working);
1639 assert!(!report.is_maker);
1640 assert_eq!(report.trade_id, -1);
1641 }
1642
1643 #[rstest]
1644 fn test_decode_captured_execution_report_canceled() {
1645 let data = crate::common::testing::load_fixture_bytes(
1646 "spot/user_data_sbe/mainnet/execution_report_event_2.sbe",
1647 );
1648 let report = decode_execution_report(&data).unwrap();
1649
1650 assert_eq!(report.symbol, "BTCUSDT");
1651 assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1652 assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1653 assert_eq!(report.order_id, 12345678);
1654 assert!(!report.is_working);
1655 }
1656
1657 #[rstest]
1658 fn test_decode_captured_account_position() {
1659 let data = crate::common::testing::load_fixture_bytes(
1660 "spot/user_data_sbe/mainnet/outbound_account_position_event_1.sbe",
1661 );
1662 let msg = decode_account_position(&data).unwrap();
1663
1664 assert_eq!(msg.event_type, "outboundAccountPosition");
1665 assert_eq!(msg.balances.len(), 3);
1666 assert_eq!(msg.balances[0].asset, "BTC");
1667 assert_eq!(
1668 msg.balances[0].free,
1669 Decimal::from_str_exact("1.00000000").unwrap()
1670 );
1671 assert_eq!(msg.balances[1].asset, "BNB");
1672 assert_eq!(msg.balances[2].asset, "USDT");
1673 assert_eq!(
1674 msg.balances[2].free,
1675 Decimal::from_str_exact("50000.00000000").unwrap()
1676 );
1677 }
1678}