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