Skip to main content

nautilus_binance/spot/websocket/trading/
decode_sbe.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! SBE decoders for Binance Spot user data stream events.
17//!
18//! Decodes templates 601 (BalanceUpdateEvent), 603 (ExecutionReportEvent), and
19//! 607 (OutboundAccountPositionEvent) from schema 3:5 binary payloads into the
20//! venue-level structs defined in [`super::user_data`].
21//! The existing JSON parse functions in [`super::parse`] then convert these to Nautilus types.
22
23use 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
41// Historical block lengths are not exposed by the generated codec
42const 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
54/// Decodes an SBE ExecutionReportEvent (template 603) into a [`BinanceSpotExecutionReport`].
55///
56/// The input buffer must include the 8-byte SBE message header.
57///
58/// # Errors
59///
60/// Returns error if the buffer is too short, the template ID is wrong,
61/// the schema ID does not match, or variable-length data is malformed.
62pub 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    // Variable-length fields must be read in schema order.
160    // Each field is converted to an owned String immediately to release the borrow.
161    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    // counter_symbol - read to advance cursor, not used in venue struct
193    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
238/// Decodes an SBE OutboundAccountPositionEvent (template 607) into a
239/// [`BinanceSpotAccountPositionMsg`].
240///
241/// The input buffer must include the 8-byte SBE message header.
242///
243/// # Errors
244///
245/// Returns error if the buffer is too short, the template ID is wrong,
246/// or the schema ID does not match.
247pub 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
320/// Decodes an SBE BalanceUpdateEvent (template 601) into a [`BinanceSpotBalanceUpdateMsg`].
321///
322/// The input buffer must include the 8-byte SBE message header.
323///
324/// # Errors
325///
326/// Returns error if the buffer is too short, the template ID is wrong,
327/// or the schema ID does not match.
328pub 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/// Converts an SBE microsecond timestamp to a JSON millisecond timestamp.
477#[inline]
478fn us_to_ms(us: i64) -> i64 {
479    if us < 0 { us } else { us / 1_000 }
480}
481
482/// Converts an SBE `mantissa * 10^exponent` pair to a [`Decimal`] without floating-point.
483fn 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
491/// Converts a mantissa + exponent pair to a decimal string without floating-point.
492///
493/// SBE encodes numeric values as `mantissa * 10^exponent`. For exponent = -2
494/// and mantissa = 250000, the result is "2500.00".
495fn 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); // null
602        enc.order_id(order_id);
603        enc.order_list_id(i64::MIN); // null
604        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); // null
652        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); // null
655        enc.pegged_price(i64::MIN);
656        enc.expiry_reason(expiry_reason);
657
658        // Variable-length fields in order
659        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)], // (asset, exponent, free, locked)
705    ) -> 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); // null
720
721        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, // no trade
815            OrderSide::Buy,
816            SbeOrderType::Limit,
817            SbeTif::Gtc,
818            ExecutionType::New,
819            OrderStatus::New,
820            -2,     // price_exp
821            -5,     // qty_exp
822            -8,     // commission_exp
823            250000, // price = 2500.00
824            100000, // orig_qty = 1.00000
825            0,      // stop_price
826            0,      // last_qty
827            0,      // last_price
828            0,      // executed_qty
829            0,      // cumm_quote_qty
830            0,      // commission
831            "",     // no commission asset yet
832            false,
833            true,             // is_working
834            1709654400000000, // event_time_us
835            1709654400000000, // transact_time_us
836            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,           // price_exp
915            -8,           // qty_exp
916            -8,           // commission_exp
917            250000,       // price = 2500.00
918            100000000,    // orig_qty = 1.00000000
919            0,            // stop_price
920            100000000,    // last_qty = 1.00000000
921            250000,       // last_price = 2500.00 (uses price_exp)
922            100000000,    // executed_qty = 1.00000000
923            250000000000, // cumm_quote_qty = 2500.00000000 (uses price_exp... actually this is wrong)
924            250000,       // commission = 0.00250000
925            "USDT",
926            true, // is_maker
927            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,  // price = 50000.00
961            10000000, // orig_qty = 0.10000000
962            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, // price = 2400.00
1001            100000, // orig_qty = 1.00000
1002            245000, // stop_price = 2450.00
1003            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        // Overwrite template_id to 50
1129        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,                            // event_time_us
1139            1709654400000000,                            // update_time_us
1140            &[("USDT", -8, 1000000000000, 50000000000)], // free=10000.00000000, locked=500.00000000
1141        );
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),      // free=1.00000000, locked=0.00000000
1166                ("USDT", -8, 5000000000000, 0), // free=50000.00000000, locked=0.00000000
1167            ],
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); // null
1230        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, // event_time_us
1239            1709654400000000, // clear_time_us
1240            -8,
1241            10000000000, // delta = 100.00000000
1242            "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}