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