Skip to main content

nautilus_binance/spot/http/
parse.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 decode functions for Binance Spot HTTP responses.
17//!
18//! Each function decodes raw SBE bytes into domain types, validating the
19//! message header (schema ID and template ID) before extracting fields.
20
21use super::{
22    error::SbeDecodeError,
23    models::{
24        BinanceAccountInfo, BinanceAccountTrade, BinanceAggTrade, BinanceAggTrades, BinanceBalance,
25        BinanceCancelOrderResponse, BinanceDepth, BinanceExchangeInfoSbe, BinanceKline,
26        BinanceKlines, BinanceLotSizeFilterSbe, BinanceNewOrderResponse, BinanceOrderFill,
27        BinanceOrderResponse, BinancePriceFilterSbe, BinancePriceLevel, BinanceSymbolFiltersSbe,
28        BinanceSymbolSbe, BinanceTrade, BinanceTrades,
29    },
30};
31use crate::spot::sbe::{
32    cursor::SbeCursor,
33    spot::{
34        SBE_SCHEMA_ID, account_response_codec::SBE_TEMPLATE_ID as ACCOUNT_TEMPLATE_ID,
35        account_trades_response_codec::SBE_TEMPLATE_ID as ACCOUNT_TRADES_TEMPLATE_ID,
36        account_type::AccountType,
37        agg_trades_response_codec::SBE_TEMPLATE_ID as AGG_TRADES_TEMPLATE_ID, bool_enum::BoolEnum,
38        cancel_open_orders_response_codec::SBE_TEMPLATE_ID as CANCEL_OPEN_ORDERS_TEMPLATE_ID,
39        cancel_order_response_codec::SBE_TEMPLATE_ID as CANCEL_ORDER_TEMPLATE_ID,
40        depth_response_codec::SBE_TEMPLATE_ID as DEPTH_TEMPLATE_ID,
41        exchange_info_response_codec::SBE_TEMPLATE_ID as EXCHANGE_INFO_TEMPLATE_ID,
42        klines_response_codec::SBE_TEMPLATE_ID as KLINES_TEMPLATE_ID,
43        lot_size_filter_codec::SBE_TEMPLATE_ID as LOT_SIZE_FILTER_TEMPLATE_ID,
44        message_header_codec::ENCODED_LENGTH as HEADER_LENGTH,
45        new_order_full_response_codec::SBE_TEMPLATE_ID as NEW_ORDER_FULL_TEMPLATE_ID,
46        order_response_codec::SBE_TEMPLATE_ID as ORDER_TEMPLATE_ID,
47        orders_response_codec::SBE_TEMPLATE_ID as ORDERS_TEMPLATE_ID,
48        ping_response_codec::SBE_TEMPLATE_ID as PING_TEMPLATE_ID,
49        price_filter_codec::SBE_TEMPLATE_ID as PRICE_FILTER_TEMPLATE_ID,
50        server_time_response_codec::SBE_TEMPLATE_ID as SERVER_TIME_TEMPLATE_ID,
51        trades_response_codec::SBE_TEMPLATE_ID as TRADES_TEMPLATE_ID,
52    },
53};
54
55/// SBE message header.
56#[derive(Debug, Clone, Copy)]
57struct MessageHeader {
58    block_length: u16,
59    template_id: u16,
60    schema_id: u16,
61}
62
63impl MessageHeader {
64    /// Decode message header using cursor.
65    fn decode_cursor(cursor: &mut SbeCursor<'_>) -> Result<Self, SbeDecodeError> {
66        cursor.require(HEADER_LENGTH)?;
67        let block_length = cursor.read_u16_le()?;
68        let template_id = cursor.read_u16_le()?;
69        let schema_id = cursor.read_u16_le()?;
70        let _version = cursor.read_u16_le()?; // Consumed to advance the cursor; not enforced, see validate().
71        Ok(Self {
72            block_length,
73            template_id,
74            schema_id,
75        })
76    }
77
78    /// Validate the message schema ID.
79    ///
80    /// The exact schema version is intentionally not enforced, matching the
81    /// WebSocket SBE path. Binance evolves the schema additively within a schema ID
82    /// and rolls new versions out gradually, so a single client sees both the current
83    /// and next version during a rollout: 3:4 and 3:5 share identical block layouts,
84    /// differing only by an added `symbolStatus` enum value, and unknown enum values
85    /// decode to their null variant. Enforcing an exact version would hard-fail
86    /// instrument loading on a server-side bump. A different schema ID is a breaking
87    /// change and is still rejected.
88    ///
89    /// The decoders assume block layouts are stable within a schema ID.
90    /// `decode_exchange_info` verifies the symbol block length and fails loudly on a
91    /// mismatch; the market-data decoders read groups at a fixed offset, so a future
92    /// version that adds fixed-block fields to those messages would need them updated
93    /// to advance past the added bytes via `block_length`.
94    fn validate(&self) -> Result<(), SbeDecodeError> {
95        if self.schema_id != SBE_SCHEMA_ID {
96            return Err(SbeDecodeError::SchemaMismatch {
97                expected: SBE_SCHEMA_ID,
98                actual: self.schema_id,
99            });
100        }
101        Ok(())
102    }
103}
104
105/// Decode a ping response.
106///
107/// Ping response has no body (block_length = 0), just validates the header.
108///
109/// # Errors
110///
111/// Returns error if buffer is too short or schema mismatch.
112pub fn decode_ping(buf: &[u8]) -> Result<(), SbeDecodeError> {
113    let mut cursor = SbeCursor::new(buf);
114    let header = MessageHeader::decode_cursor(&mut cursor)?;
115    header.validate()?;
116
117    if header.template_id != PING_TEMPLATE_ID {
118        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
119    }
120
121    Ok(())
122}
123
124/// Decode a server time response.
125///
126/// Returns the server time as **microseconds** since epoch (SBE provides
127/// microsecond precision vs JSON's milliseconds).
128///
129/// # Errors
130///
131/// Returns error if buffer is too short or schema mismatch.
132pub fn decode_server_time(buf: &[u8]) -> Result<i64, SbeDecodeError> {
133    let mut cursor = SbeCursor::new(buf);
134    let header = MessageHeader::decode_cursor(&mut cursor)?;
135    header.validate()?;
136
137    if header.template_id != SERVER_TIME_TEMPLATE_ID {
138        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
139    }
140
141    cursor.read_i64_le()
142}
143
144/// Decode a depth response.
145///
146/// Returns the order book depth with bids and asks.
147///
148/// # Errors
149///
150/// Returns error if buffer is too short, schema mismatch, or group size exceeded.
151pub fn decode_depth(buf: &[u8]) -> Result<BinanceDepth, SbeDecodeError> {
152    let mut cursor = SbeCursor::new(buf);
153    let header = MessageHeader::decode_cursor(&mut cursor)?;
154    header.validate()?;
155
156    if header.template_id != DEPTH_TEMPLATE_ID {
157        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
158    }
159
160    let last_update_id = cursor.read_i64_le()?;
161    let price_exponent = cursor.read_i8()?;
162    let qty_exponent = cursor.read_i8()?;
163
164    let (block_len, count) = cursor.read_group_header()?;
165    let bids = cursor.read_group(block_len, count, |c| {
166        Ok(BinancePriceLevel {
167            price_mantissa: c.read_i64_le()?,
168            qty_mantissa: c.read_i64_le()?,
169        })
170    })?;
171
172    let (block_len, count) = cursor.read_group_header()?;
173    let asks = cursor.read_group(block_len, count, |c| {
174        Ok(BinancePriceLevel {
175            price_mantissa: c.read_i64_le()?,
176            qty_mantissa: c.read_i64_le()?,
177        })
178    })?;
179
180    Ok(BinanceDepth {
181        last_update_id,
182        price_exponent,
183        qty_exponent,
184        bids,
185        asks,
186    })
187}
188
189/// Decode a trades response.
190///
191/// Returns the list of trades.
192///
193/// # Errors
194///
195/// Returns error if buffer is too short, schema mismatch, or group size exceeded.
196pub fn decode_trades(buf: &[u8]) -> Result<BinanceTrades, SbeDecodeError> {
197    let mut cursor = SbeCursor::new(buf);
198    let header = MessageHeader::decode_cursor(&mut cursor)?;
199    header.validate()?;
200
201    if header.template_id != TRADES_TEMPLATE_ID {
202        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
203    }
204
205    let price_exponent = cursor.read_i8()?;
206    let qty_exponent = cursor.read_i8()?;
207
208    let (block_len, count) = cursor.read_group_header()?;
209    let trades = cursor.read_group(block_len, count, |c| {
210        Ok(BinanceTrade {
211            id: c.read_i64_le()?,
212            price_mantissa: c.read_i64_le()?,
213            qty_mantissa: c.read_i64_le()?,
214            quote_qty_mantissa: c.read_i64_le()?,
215            time: c.read_i64_le()?,
216            is_buyer_maker: BoolEnum::from(c.read_u8()?) == BoolEnum::True,
217            is_best_match: BoolEnum::from(c.read_u8()?) == BoolEnum::True,
218        })
219    })?;
220
221    Ok(BinanceTrades {
222        price_exponent,
223        qty_exponent,
224        trades,
225    })
226}
227
228/// Decodes an aggregate trades response.
229///
230/// # Errors
231///
232/// Returns an error for an invalid header, template, or group payload.
233pub fn decode_agg_trades(buf: &[u8]) -> Result<BinanceAggTrades, SbeDecodeError> {
234    let mut cursor = SbeCursor::new(buf);
235    let header = MessageHeader::decode_cursor(&mut cursor)?;
236    header.validate()?;
237
238    if header.template_id != AGG_TRADES_TEMPLATE_ID {
239        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
240    }
241
242    let price_exponent = cursor.read_i8()?;
243    let qty_exponent = cursor.read_i8()?;
244    let (block_len, count) = cursor.read_group_header()?;
245    let trades = cursor.read_group(block_len, count, |c| {
246        Ok(BinanceAggTrade {
247            id: c.read_i64_le()?,
248            price_mantissa: c.read_i64_le()?,
249            qty_mantissa: c.read_i64_le()?,
250            first_trade_id: c.read_i64_le()?,
251            last_trade_id: c.read_i64_le()?,
252            time: c.read_i64_le()?,
253            is_buyer_maker: BoolEnum::from(c.read_u8()?) == BoolEnum::True,
254            is_best_match: BoolEnum::from(c.read_u8()?) == BoolEnum::True,
255        })
256    })?;
257
258    Ok(BinanceAggTrades {
259        price_exponent,
260        qty_exponent,
261        trades,
262    })
263}
264
265/// Klines group item block length (from SBE codec).
266const KLINES_BLOCK_LENGTH: u16 = 120;
267
268/// Decode a klines (candlestick) response.
269///
270/// Returns the list of klines with their price and quantity exponents.
271///
272/// # Errors
273///
274/// Returns error if buffer is too short, schema mismatch, or group size exceeded.
275pub fn decode_klines(buf: &[u8]) -> Result<BinanceKlines, SbeDecodeError> {
276    let mut cursor = SbeCursor::new(buf);
277    let header = MessageHeader::decode_cursor(&mut cursor)?;
278    header.validate()?;
279
280    if header.template_id != KLINES_TEMPLATE_ID {
281        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
282    }
283
284    let price_exponent = cursor.read_i8()?;
285    let qty_exponent = cursor.read_i8()?;
286
287    let (block_len, count) = cursor.read_group_header()?;
288
289    if block_len != KLINES_BLOCK_LENGTH {
290        return Err(SbeDecodeError::InvalidBlockLength {
291            expected: KLINES_BLOCK_LENGTH,
292            actual: block_len,
293        });
294    }
295
296    let mut klines = Vec::with_capacity(count as usize);
297
298    for _ in 0..count {
299        cursor.require(KLINES_BLOCK_LENGTH as usize)?;
300
301        let open_time = cursor.read_i64_le()?;
302        let open_price = cursor.read_i64_le()?;
303        let high_price = cursor.read_i64_le()?;
304        let low_price = cursor.read_i64_le()?;
305        let close_price = cursor.read_i64_le()?;
306
307        let volume_slice = cursor.read_bytes(16)?;
308        let mut volume = [0u8; 16];
309        volume.copy_from_slice(volume_slice);
310
311        let close_time = cursor.read_i64_le()?;
312
313        let quote_volume_slice = cursor.read_bytes(16)?;
314        let mut quote_volume = [0u8; 16];
315        quote_volume.copy_from_slice(quote_volume_slice);
316
317        let num_trades = cursor.read_i64_le()?;
318
319        let taker_buy_base_volume_slice = cursor.read_bytes(16)?;
320        let mut taker_buy_base_volume = [0u8; 16];
321        taker_buy_base_volume.copy_from_slice(taker_buy_base_volume_slice);
322
323        let taker_buy_quote_volume_slice = cursor.read_bytes(16)?;
324        let mut taker_buy_quote_volume = [0u8; 16];
325        taker_buy_quote_volume.copy_from_slice(taker_buy_quote_volume_slice);
326
327        klines.push(BinanceKline {
328            open_time,
329            open_price,
330            high_price,
331            low_price,
332            close_price,
333            volume,
334            close_time,
335            quote_volume,
336            num_trades,
337            taker_buy_base_volume,
338            taker_buy_quote_volume,
339        });
340    }
341
342    Ok(BinanceKlines {
343        price_exponent,
344        qty_exponent,
345        klines,
346    })
347}
348
349/// Bytes consumed from the fixed block before the end-of-block skip in each decoder.
350/// These represent explicit reads and advances up to the last field we extract.
351const NEW_ORDER_FULL_FIELDS_END: usize = 135;
352const CANCEL_ORDER_FIELDS_END: usize = 63;
353const ORDER_FIELDS_END: usize = 104;
354
355/// Sentinel value for a null `expiryReason` in schema 3:4.
356const EXPIRY_REASON_NULL: u8 = 0xff;
357
358/// Schema-3:4 byte offsets of the `expiryReason` field within each fixed block.
359/// Sourced from the SBE codecs: `newOrderFullResponse` puts it at 153,
360/// `orderResponse` / `ordersResponse` at 162.
361const NEW_ORDER_FULL_EXPIRY_REASON_OFFSET: usize = 153;
362const ORDER_EXPIRY_REASON_OFFSET: usize = 162;
363const ORDERS_GROUP_EXPIRY_REASON_OFFSET: usize = 162;
364
365/// Reads the schema-3:4 `expiryReason` byte from the fixed block when present.
366///
367/// `fields_end` is the cursor position (in bytes from the start of the block)
368/// after the last field the caller has explicitly parsed. `expiry_reason_offset`
369/// is the field's encoded offset within the block per the SBE schema.
370/// Returns `Ok(None)` when the runtime `block_length` does not span the
371/// `expiryReason` byte (schema 3:3 layouts) or when the byte holds the SBE
372/// null sentinel. In every case the cursor is advanced to the end of the
373/// fixed block.
374fn read_trailing_expiry_reason(
375    cursor: &mut SbeCursor<'_>,
376    block_length: usize,
377    fields_end: usize,
378    expiry_reason_offset: usize,
379) -> Result<Option<u8>, SbeDecodeError> {
380    debug_assert!(fields_end <= expiry_reason_offset);
381    if block_length < fields_end {
382        return Ok(None);
383    }
384    let trailer = block_length - fields_end;
385    if trailer == 0 {
386        return Ok(None);
387    }
388    // Pre-3:4 block: no expiryReason byte at this offset, skip remaining bytes.
389    if block_length <= expiry_reason_offset {
390        cursor.advance(trailer)?;
391        return Ok(None);
392    }
393    let pre = expiry_reason_offset - fields_end;
394    cursor.advance(pre)?;
395    let byte = cursor.read_u8()?;
396    let post = trailer - pre - 1;
397    if post > 0 {
398        cursor.advance(post)?;
399    }
400    Ok((byte != EXPIRY_REASON_NULL).then_some(byte))
401}
402
403/// Decode a new order full response.
404///
405/// # Errors
406///
407/// Returns error if buffer is too short, schema mismatch, or decode error.
408#[allow(dead_code)]
409pub fn decode_new_order_full(buf: &[u8]) -> Result<BinanceNewOrderResponse, SbeDecodeError> {
410    let mut cursor = SbeCursor::new(buf);
411    let header = MessageHeader::decode_cursor(&mut cursor)?;
412    header.validate()?;
413
414    if header.template_id != NEW_ORDER_FULL_TEMPLATE_ID {
415        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
416    }
417
418    cursor.require(header.block_length as usize)?;
419
420    let price_exponent = cursor.read_i8()?;
421    let qty_exponent = cursor.read_i8()?;
422    let order_id = cursor.read_i64_le()?;
423    let order_list_id = cursor.read_optional_i64_le()?;
424    let transact_time = cursor.read_i64_le()?;
425    let price_mantissa = cursor.read_i64_le()?;
426    let orig_qty_mantissa = cursor.read_i64_le()?;
427    let executed_qty_mantissa = cursor.read_i64_le()?;
428    let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
429    let status = cursor.read_u8()?.into();
430    let time_in_force = cursor.read_u8()?.into();
431    let order_type = cursor.read_u8()?.into();
432    let side = cursor.read_u8()?.into();
433    let stop_price_mantissa = cursor.read_optional_i64_le()?;
434
435    cursor.advance(16)?; // Skip trailing_delta (8) + trailing_time (8)
436    let working_time = cursor.read_optional_i64_le()?;
437
438    cursor.advance(23)?; // Skip iceberg to used_sor
439    let self_trade_prevention_mode = cursor.read_u8()?.into();
440
441    cursor.advance(16)?; // Skip trade_group_id + prevented_quantity
442    let _commission_exponent = cursor.read_i8()?;
443
444    let expiry_reason = read_trailing_expiry_reason(
445        &mut cursor,
446        header.block_length as usize,
447        NEW_ORDER_FULL_FIELDS_END,
448        NEW_ORDER_FULL_EXPIRY_REASON_OFFSET,
449    )?;
450
451    let fills = decode_fills_cursor(&mut cursor)?;
452
453    // Skip prevented matches group
454    let (block_len, count) = cursor.read_group_header()?;
455    cursor.advance(block_len as usize * count as usize)?;
456
457    let symbol = cursor.read_var_string8()?;
458    let client_order_id = cursor.read_var_string8()?;
459
460    Ok(BinanceNewOrderResponse {
461        price_exponent,
462        qty_exponent,
463        order_id,
464        order_list_id,
465        transact_time,
466        price_mantissa,
467        orig_qty_mantissa,
468        executed_qty_mantissa,
469        cummulative_quote_qty_mantissa,
470        status,
471        time_in_force,
472        order_type,
473        side,
474        stop_price_mantissa,
475        working_time,
476        self_trade_prevention_mode,
477        client_order_id,
478        symbol,
479        fills,
480        expiry_reason,
481    })
482}
483
484/// Decode a cancel order response.
485///
486/// # Errors
487///
488/// Returns error if buffer is too short, schema mismatch, or decode error.
489#[allow(dead_code)]
490pub fn decode_cancel_order(buf: &[u8]) -> Result<BinanceCancelOrderResponse, SbeDecodeError> {
491    let mut cursor = SbeCursor::new(buf);
492    let header = MessageHeader::decode_cursor(&mut cursor)?;
493    header.validate()?;
494
495    if header.template_id != CANCEL_ORDER_TEMPLATE_ID {
496        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
497    }
498
499    cursor.require(header.block_length as usize)?;
500
501    let price_exponent = cursor.read_i8()?;
502    let qty_exponent = cursor.read_i8()?;
503    let order_id = cursor.read_i64_le()?;
504    let order_list_id = cursor.read_optional_i64_le()?;
505    let transact_time = cursor.read_i64_le()?;
506    let price_mantissa = cursor.read_i64_le()?;
507    let orig_qty_mantissa = cursor.read_i64_le()?;
508    let executed_qty_mantissa = cursor.read_i64_le()?;
509    let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
510    let status = cursor.read_u8()?.into();
511    let time_in_force = cursor.read_u8()?.into();
512    let order_type = cursor.read_u8()?.into();
513    let side = cursor.read_u8()?.into();
514    let self_trade_prevention_mode = cursor.read_u8()?.into();
515
516    cursor.advance(header.block_length as usize - CANCEL_ORDER_FIELDS_END)?;
517
518    let symbol = cursor.read_var_string8()?;
519    let orig_client_order_id = cursor.read_var_string8()?;
520    let client_order_id = cursor.read_var_string8()?;
521
522    Ok(BinanceCancelOrderResponse {
523        price_exponent,
524        qty_exponent,
525        order_id,
526        order_list_id,
527        transact_time,
528        price_mantissa,
529        orig_qty_mantissa,
530        executed_qty_mantissa,
531        cummulative_quote_qty_mantissa,
532        status,
533        time_in_force,
534        order_type,
535        side,
536        self_trade_prevention_mode,
537        client_order_id,
538        orig_client_order_id,
539        symbol,
540    })
541}
542
543/// Decode an order query response.
544///
545/// # Errors
546///
547/// Returns error if buffer is too short, schema mismatch, or decode error.
548#[allow(dead_code)]
549pub fn decode_order(buf: &[u8]) -> Result<BinanceOrderResponse, SbeDecodeError> {
550    let mut cursor = SbeCursor::new(buf);
551    let header = MessageHeader::decode_cursor(&mut cursor)?;
552    header.validate()?;
553
554    if header.template_id != ORDER_TEMPLATE_ID {
555        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
556    }
557
558    cursor.require(header.block_length as usize)?;
559
560    let price_exponent = cursor.read_i8()?;
561    let qty_exponent = cursor.read_i8()?;
562    let order_id = cursor.read_i64_le()?;
563    let order_list_id = cursor.read_optional_i64_le()?;
564    let price_mantissa = cursor.read_i64_le()?;
565    let orig_qty_mantissa = cursor.read_i64_le()?;
566    let executed_qty_mantissa = cursor.read_i64_le()?;
567    let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
568    let status = cursor.read_u8()?.into();
569    let time_in_force = cursor.read_u8()?.into();
570    let order_type = cursor.read_u8()?.into();
571    let side = cursor.read_u8()?.into();
572    let stop_price_mantissa = cursor.read_optional_i64_le()?;
573    let iceberg_qty_mantissa = cursor.read_optional_i64_le()?;
574    let time = cursor.read_i64_le()?;
575    let update_time = cursor.read_i64_le()?;
576    let is_working = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
577    let working_time = cursor.read_optional_i64_le()?;
578    let orig_quote_order_qty_mantissa = cursor.read_i64_le()?;
579    let self_trade_prevention_mode = cursor.read_u8()?.into();
580
581    let expiry_reason = read_trailing_expiry_reason(
582        &mut cursor,
583        header.block_length as usize,
584        ORDER_FIELDS_END,
585        ORDER_EXPIRY_REASON_OFFSET,
586    )?;
587
588    let symbol = cursor.read_var_string8()?;
589    let client_order_id = cursor.read_var_string8()?;
590
591    Ok(BinanceOrderResponse {
592        price_exponent,
593        qty_exponent,
594        order_id,
595        order_list_id,
596        price_mantissa,
597        orig_qty_mantissa,
598        executed_qty_mantissa,
599        cummulative_quote_qty_mantissa,
600        status,
601        time_in_force,
602        order_type,
603        side,
604        stop_price_mantissa,
605        iceberg_qty_mantissa,
606        time,
607        update_time,
608        is_working,
609        working_time,
610        orig_quote_order_qty_mantissa,
611        self_trade_prevention_mode,
612        client_order_id,
613        symbol,
614        expiry_reason,
615    })
616}
617
618/// Minimum block length for orders group item (schema 3:3 baseline).
619const ORDERS_GROUP_MIN_BLOCK_LENGTH: u16 = 162;
620
621/// Bytes consumed up to and including self_trade_prevention_mode; the remaining
622/// bytes of the fixed block are skipped via the group's runtime block length so
623/// new trailing fields (e.g. v4 expiryReason) round-trip without changes here.
624const ORDERS_GROUP_FIELDS_END: usize = 134;
625
626/// Decode multiple orders response.
627///
628/// # Errors
629///
630/// Returns error if buffer is too short, schema mismatch, or decode error.
631#[allow(dead_code)]
632pub fn decode_orders(buf: &[u8]) -> Result<Vec<BinanceOrderResponse>, SbeDecodeError> {
633    let mut cursor = SbeCursor::new(buf);
634    let header = MessageHeader::decode_cursor(&mut cursor)?;
635    header.validate()?;
636
637    if header.template_id != ORDERS_TEMPLATE_ID {
638        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
639    }
640
641    let (block_length, count) = cursor.read_group_header()?;
642
643    if count == 0 {
644        return Ok(Vec::new());
645    }
646
647    if block_length < ORDERS_GROUP_MIN_BLOCK_LENGTH {
648        return Err(SbeDecodeError::InvalidBlockLength {
649            expected: ORDERS_GROUP_MIN_BLOCK_LENGTH,
650            actual: block_length,
651        });
652    }
653
654    let mut orders = Vec::with_capacity(count as usize);
655
656    for _ in 0..count {
657        cursor.require(block_length as usize)?;
658
659        let price_exponent = cursor.read_i8()?;
660        let qty_exponent = cursor.read_i8()?;
661        let order_id = cursor.read_i64_le()?;
662        let order_list_id = cursor.read_optional_i64_le()?;
663        let price_mantissa = cursor.read_i64_le()?;
664        let orig_qty_mantissa = cursor.read_i64_le()?;
665        let executed_qty_mantissa = cursor.read_i64_le()?;
666        let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
667        let status = cursor.read_u8()?.into();
668        let time_in_force = cursor.read_u8()?.into();
669        let order_type = cursor.read_u8()?.into();
670        let side = cursor.read_u8()?.into();
671        let stop_price_mantissa = cursor.read_optional_i64_le()?;
672
673        cursor.advance(16)?; // Skip trailing_delta + trailing_time
674        let iceberg_qty_mantissa = cursor.read_optional_i64_le()?;
675        let time = cursor.read_i64_le()?;
676        let update_time = cursor.read_i64_le()?;
677        let is_working = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
678        let working_time = cursor.read_optional_i64_le()?;
679        let orig_quote_order_qty_mantissa = cursor.read_i64_le()?;
680
681        cursor.advance(14)?; // Skip strategy_id to working_floor
682        let self_trade_prevention_mode = cursor.read_u8()?.into();
683
684        let expiry_reason = read_trailing_expiry_reason(
685            &mut cursor,
686            block_length as usize,
687            ORDERS_GROUP_FIELDS_END,
688            ORDERS_GROUP_EXPIRY_REASON_OFFSET,
689        )?;
690
691        let symbol = cursor.read_var_string8()?;
692        let client_order_id = cursor.read_var_string8()?;
693
694        orders.push(BinanceOrderResponse {
695            price_exponent,
696            qty_exponent,
697            order_id,
698            order_list_id,
699            price_mantissa,
700            orig_qty_mantissa,
701            executed_qty_mantissa,
702            cummulative_quote_qty_mantissa,
703            status,
704            time_in_force,
705            order_type,
706            side,
707            stop_price_mantissa,
708            iceberg_qty_mantissa,
709            time,
710            update_time,
711            is_working,
712            working_time,
713            orig_quote_order_qty_mantissa,
714            self_trade_prevention_mode,
715            client_order_id,
716            symbol,
717            expiry_reason,
718        });
719    }
720
721    Ok(orders)
722}
723
724/// Decode cancel open orders response.
725///
726/// Each item in the response group contains an embedded cancel_order_response SBE message.
727///
728/// # Errors
729///
730/// Returns error if buffer is too short, schema mismatch, or decode error.
731#[allow(dead_code)]
732pub fn decode_cancel_open_orders(
733    buf: &[u8],
734) -> Result<Vec<BinanceCancelOrderResponse>, SbeDecodeError> {
735    let mut cursor = SbeCursor::new(buf);
736    let header = MessageHeader::decode_cursor(&mut cursor)?;
737    header.validate()?;
738
739    if header.template_id != CANCEL_OPEN_ORDERS_TEMPLATE_ID {
740        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
741    }
742
743    let (_block_length, count) = cursor.read_group_header()?;
744
745    if count == 0 {
746        return Ok(Vec::new());
747    }
748
749    let mut responses = Vec::with_capacity(count as usize);
750
751    // Each group item has block_length=0, followed by u16 length + embedded SBE message
752    for _ in 0..count {
753        let response_len = cursor.read_u16_le()? as usize;
754        let embedded_bytes = cursor.read_bytes(response_len)?;
755        let cancel_response = decode_cancel_order(embedded_bytes)?;
756        responses.push(cancel_response);
757    }
758
759    Ok(responses)
760}
761
762/// Account response block length (from SBE codec).
763const ACCOUNT_BLOCK_LENGTH: usize = 64;
764
765/// Balance group item block length (from SBE codec).
766const BALANCE_BLOCK_LENGTH: u16 = 17;
767
768/// Decode account information response.
769///
770/// # Errors
771///
772/// Returns error if buffer is too short, schema mismatch, or decode error.
773#[allow(dead_code)]
774pub fn decode_account(buf: &[u8]) -> Result<BinanceAccountInfo, SbeDecodeError> {
775    let mut cursor = SbeCursor::new(buf);
776    let header = MessageHeader::decode_cursor(&mut cursor)?;
777    header.validate()?;
778
779    if header.template_id != ACCOUNT_TEMPLATE_ID {
780        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
781    }
782
783    cursor.require(ACCOUNT_BLOCK_LENGTH)?;
784
785    let commission_exponent = cursor.read_i8()?;
786    let maker_commission_mantissa = cursor.read_i64_le()?;
787    let taker_commission_mantissa = cursor.read_i64_le()?;
788    let buyer_commission_mantissa = cursor.read_i64_le()?;
789    let seller_commission_mantissa = cursor.read_i64_le()?;
790    let can_trade = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
791    let can_withdraw = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
792    let can_deposit = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
793    cursor.advance(1)?; // Skip brokered
794    let require_self_trade_prevention = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
795    let prevent_sor = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
796    let update_time = cursor.read_i64_le()?;
797    let account_type_enum = AccountType::from(cursor.read_u8()?);
798    cursor.advance(16)?; // Skip tradeGroupId + uid
799
800    let account_type = account_type_enum.to_string();
801
802    let (block_length, balance_count) = cursor.read_group_header()?;
803
804    if block_length != BALANCE_BLOCK_LENGTH {
805        return Err(SbeDecodeError::InvalidBlockLength {
806            expected: BALANCE_BLOCK_LENGTH,
807            actual: block_length,
808        });
809    }
810
811    let mut balances = Vec::with_capacity(balance_count as usize);
812
813    for _ in 0..balance_count {
814        cursor.require(block_length as usize)?;
815
816        let exponent = cursor.read_i8()?;
817        let free_mantissa = cursor.read_i64_le()?;
818        let locked_mantissa = cursor.read_i64_le()?;
819
820        let asset = cursor.read_var_string8()?;
821
822        balances.push(BinanceBalance {
823            asset,
824            free_mantissa,
825            locked_mantissa,
826            exponent,
827        });
828    }
829
830    Ok(BinanceAccountInfo {
831        commission_exponent,
832        maker_commission_mantissa,
833        taker_commission_mantissa,
834        buyer_commission_mantissa,
835        seller_commission_mantissa,
836        can_trade,
837        can_withdraw,
838        can_deposit,
839        require_self_trade_prevention,
840        prevent_sor,
841        update_time,
842        account_type,
843        balances,
844    })
845}
846
847/// Account trade group item block length (from SBE codec).
848const ACCOUNT_TRADE_BLOCK_LENGTH: u16 = 70;
849
850/// Decode account trades response.
851///
852/// # Errors
853///
854/// Returns error if buffer is too short, schema mismatch, or decode error.
855#[allow(dead_code)]
856pub fn decode_account_trades(buf: &[u8]) -> Result<Vec<BinanceAccountTrade>, SbeDecodeError> {
857    let mut cursor = SbeCursor::new(buf);
858    let header = MessageHeader::decode_cursor(&mut cursor)?;
859    header.validate()?;
860
861    if header.template_id != ACCOUNT_TRADES_TEMPLATE_ID {
862        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
863    }
864
865    let (block_length, trade_count) = cursor.read_group_header()?;
866
867    if block_length != ACCOUNT_TRADE_BLOCK_LENGTH {
868        return Err(SbeDecodeError::InvalidBlockLength {
869            expected: ACCOUNT_TRADE_BLOCK_LENGTH,
870            actual: block_length,
871        });
872    }
873
874    let mut trades = Vec::with_capacity(trade_count as usize);
875
876    for _ in 0..trade_count {
877        cursor.require(block_length as usize)?;
878
879        let price_exponent = cursor.read_i8()?;
880        let qty_exponent = cursor.read_i8()?;
881        let commission_exponent = cursor.read_i8()?;
882        let id = cursor.read_i64_le()?;
883        let order_id = cursor.read_i64_le()?;
884        let order_list_id = cursor.read_optional_i64_le()?;
885        let price_mantissa = cursor.read_i64_le()?;
886        let qty_mantissa = cursor.read_i64_le()?;
887        let quote_qty_mantissa = cursor.read_i64_le()?;
888        let commission_mantissa = cursor.read_i64_le()?;
889        let time = cursor.read_i64_le()?;
890        let is_buyer = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
891        let is_maker = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
892        let is_best_match = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
893
894        let symbol = cursor.read_var_string8()?;
895        let commission_asset = cursor.read_var_string8()?;
896
897        trades.push(BinanceAccountTrade {
898            price_exponent,
899            qty_exponent,
900            commission_exponent,
901            id,
902            order_id,
903            order_list_id,
904            price_mantissa,
905            qty_mantissa,
906            quote_qty_mantissa,
907            commission_mantissa,
908            time,
909            is_buyer,
910            is_maker,
911            is_best_match,
912            symbol,
913            commission_asset,
914        });
915    }
916
917    Ok(trades)
918}
919
920/// Fills group item block length (from SBE codec).
921const FILLS_BLOCK_LENGTH: u16 = 42;
922
923/// Decode order fills using cursor.
924fn decode_fills_cursor(
925    cursor: &mut SbeCursor<'_>,
926) -> Result<Vec<BinanceOrderFill>, SbeDecodeError> {
927    let (block_length, count) = cursor.read_group_header()?;
928
929    if block_length != FILLS_BLOCK_LENGTH {
930        return Err(SbeDecodeError::InvalidBlockLength {
931            expected: FILLS_BLOCK_LENGTH,
932            actual: block_length,
933        });
934    }
935
936    let mut fills = Vec::with_capacity(count as usize);
937
938    for _ in 0..count {
939        cursor.require(block_length as usize)?;
940
941        let commission_exponent = cursor.read_i8()?;
942        cursor.advance(1)?; // Skip matchType
943        let price_mantissa = cursor.read_i64_le()?;
944        let qty_mantissa = cursor.read_i64_le()?;
945        let commission_mantissa = cursor.read_i64_le()?;
946        let trade_id = cursor.read_optional_i64_le()?;
947        cursor.advance(8)?; // Skip allocId
948
949        let commission_asset = cursor.read_var_string8()?;
950
951        fills.push(BinanceOrderFill {
952            price_mantissa,
953            qty_mantissa,
954            commission_mantissa,
955            commission_exponent,
956            commission_asset,
957            trade_id,
958        });
959    }
960
961    Ok(fills)
962}
963
964/// Symbols group block length (from SBE codec).
965const SYMBOL_BLOCK_LENGTH: usize = 19;
966
967/// Decode exchange info response.
968///
969/// ExchangeInfo response contains rate limits, exchange filters, symbols, and SOR info.
970/// We only decode the symbols array which contains instrument definitions.
971///
972/// # Errors
973///
974/// Returns error if buffer is too short, schema mismatch, or template ID mismatch.
975///
976/// # Panics
977///
978/// This function will panic if filter byte slices cannot be converted to fixed-size arrays,
979/// which should not occur if the SBE data is well-formed.
980pub fn decode_exchange_info(buf: &[u8]) -> Result<BinanceExchangeInfoSbe, SbeDecodeError> {
981    let mut cursor = SbeCursor::new(buf);
982    let header = MessageHeader::decode_cursor(&mut cursor)?;
983    header.validate()?;
984
985    if header.template_id != EXCHANGE_INFO_TEMPLATE_ID {
986        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
987    }
988
989    // Skip rate_limits group
990    let (rate_limits_block_len, rate_limits_count) = cursor.read_group_header()?;
991    cursor.advance(rate_limits_block_len as usize * rate_limits_count as usize)?;
992
993    // Skip exchange_filters group
994    let (_exchange_filters_block_len, exchange_filters_count) = cursor.read_group_header()?;
995    for _ in 0..exchange_filters_count {
996        // Each filter is a varString8
997        cursor.read_var_string8()?;
998    }
999
1000    // Decode symbols group
1001    let (symbols_block_len, symbols_count) = cursor.read_group_header()?;
1002
1003    if symbols_block_len != SYMBOL_BLOCK_LENGTH as u16 {
1004        return Err(SbeDecodeError::InvalidBlockLength {
1005            expected: SYMBOL_BLOCK_LENGTH as u16,
1006            actual: symbols_block_len,
1007        });
1008    }
1009
1010    let mut symbols = Vec::with_capacity(symbols_count as usize);
1011
1012    for _ in 0..symbols_count {
1013        cursor.require(SYMBOL_BLOCK_LENGTH)?;
1014
1015        // Fixed fields (19 bytes)
1016        let status = cursor.read_u8()?;
1017        let base_asset_precision = cursor.read_u8()?;
1018        let quote_asset_precision = cursor.read_u8()?;
1019        let _base_commission_precision = cursor.read_u8()?;
1020        let _quote_commission_precision = cursor.read_u8()?;
1021        let order_types = cursor.read_u16_le()?;
1022        let iceberg_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1023        let oco_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1024        let oto_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1025        let quote_order_qty_market_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1026        let allow_trailing_stop = cursor.read_u8()? == BoolEnum::True as u8;
1027        let cancel_replace_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1028        let amend_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1029        let is_spot_trading_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1030        let is_margin_trading_allowed = cursor.read_u8()? == BoolEnum::True as u8;
1031        let _default_self_trade_prevention_mode = cursor.read_u8()?;
1032        let _allowed_self_trade_prevention_modes = cursor.read_u8()?;
1033        let _peg_instructions_allowed = cursor.read_u8()?;
1034
1035        let (_filters_block_len, filters_count) = cursor.read_group_header()?;
1036        let mut filters = BinanceSymbolFiltersSbe::default();
1037
1038        for _ in 0..filters_count {
1039            let filter_bytes = cursor.read_var_bytes8()?;
1040
1041            // Filters can have header (8 bytes) or be raw body only,
1042            // detect format by checking if bytes [2..4] contain a valid template_id
1043            let (template_id, offset) = if filter_bytes.len() >= HEADER_LENGTH + 2 {
1044                let potential_template = u16::from_le_bytes([filter_bytes[2], filter_bytes[3]]);
1045                if potential_template == PRICE_FILTER_TEMPLATE_ID
1046                    || potential_template == LOT_SIZE_FILTER_TEMPLATE_ID
1047                {
1048                    (potential_template, HEADER_LENGTH)
1049                } else {
1050                    let raw_template = u16::from_le_bytes([filter_bytes[0], filter_bytes[1]]);
1051                    (raw_template, 2)
1052                }
1053            } else if filter_bytes.len() >= 2 {
1054                let raw_template = u16::from_le_bytes([filter_bytes[0], filter_bytes[1]]);
1055                (raw_template, 2)
1056            } else {
1057                continue;
1058            };
1059
1060            // Filter body layout: exponent(1) + min(8) + max(8) + size(8) = 25 bytes
1061            match template_id {
1062                PRICE_FILTER_TEMPLATE_ID if filter_bytes.len() >= offset + 25 => {
1063                    let price_exp = filter_bytes[offset] as i8;
1064                    let min_price = i64::from_le_bytes(
1065                        filter_bytes[offset + 1..offset + 9].try_into().unwrap(),
1066                    );
1067                    let max_price = i64::from_le_bytes(
1068                        filter_bytes[offset + 9..offset + 17].try_into().unwrap(),
1069                    );
1070                    let tick_size = i64::from_le_bytes(
1071                        filter_bytes[offset + 17..offset + 25].try_into().unwrap(),
1072                    );
1073                    filters.price_filter = Some(BinancePriceFilterSbe {
1074                        price_exponent: price_exp,
1075                        min_price,
1076                        max_price,
1077                        tick_size,
1078                    });
1079                }
1080                LOT_SIZE_FILTER_TEMPLATE_ID if filter_bytes.len() >= offset + 25 => {
1081                    let qty_exp = filter_bytes[offset] as i8;
1082                    let min_qty = i64::from_le_bytes(
1083                        filter_bytes[offset + 1..offset + 9].try_into().unwrap(),
1084                    );
1085                    let max_qty = i64::from_le_bytes(
1086                        filter_bytes[offset + 9..offset + 17].try_into().unwrap(),
1087                    );
1088                    let step_size = i64::from_le_bytes(
1089                        filter_bytes[offset + 17..offset + 25].try_into().unwrap(),
1090                    );
1091                    filters.lot_size_filter = Some(BinanceLotSizeFilterSbe {
1092                        qty_exponent: qty_exp,
1093                        min_qty,
1094                        max_qty,
1095                        step_size,
1096                    });
1097                }
1098                _ => {}
1099            }
1100        }
1101
1102        // Permission sets nested group
1103        let (_perm_sets_block_len, perm_sets_count) = cursor.read_group_header()?;
1104        let mut permissions = Vec::with_capacity(perm_sets_count as usize);
1105        for _ in 0..perm_sets_count {
1106            // Permissions nested group
1107            let (_perms_block_len, perms_count) = cursor.read_group_header()?;
1108            let mut perm_set = Vec::with_capacity(perms_count as usize);
1109            for _ in 0..perms_count {
1110                let perm = cursor.read_var_string8()?;
1111                perm_set.push(perm);
1112            }
1113            permissions.push(perm_set);
1114        }
1115
1116        // Variable-length strings
1117        let symbol = cursor.read_var_string8()?;
1118        let base_asset = cursor.read_var_string8()?;
1119        let quote_asset = cursor.read_var_string8()?;
1120
1121        symbols.push(BinanceSymbolSbe {
1122            symbol,
1123            base_asset,
1124            quote_asset,
1125            base_asset_precision,
1126            quote_asset_precision,
1127            status,
1128            order_types,
1129            iceberg_allowed,
1130            oco_allowed,
1131            oto_allowed,
1132            quote_order_qty_market_allowed,
1133            allow_trailing_stop,
1134            cancel_replace_allowed,
1135            amend_allowed,
1136            is_spot_trading_allowed,
1137            is_margin_trading_allowed,
1138            filters,
1139            permissions,
1140        });
1141    }
1142
1143    // Skip SOR group (we don't need it)
1144
1145    Ok(BinanceExchangeInfoSbe { symbols })
1146}
1147
1148#[cfg(test)]
1149mod tests {
1150    use rstest::rstest;
1151
1152    use super::*;
1153    use crate::spot::sbe::spot::SBE_SCHEMA_VERSION;
1154
1155    /// Schema v1 block length for new order full response (template 302).
1156    const NEW_ORDER_FULL_BLOCK_LENGTH: usize = 153;
1157
1158    /// Schema v1 block length for cancel order response (template 305).
1159    const CANCEL_ORDER_BLOCK_LENGTH: usize = 137;
1160
1161    /// Schema v1 block length for order response / query (template 304).
1162    const ORDER_BLOCK_LENGTH: usize = 153;
1163
1164    fn create_header(block_length: u16, template_id: u16, schema_id: u16, version: u16) -> [u8; 8] {
1165        let mut buf = [0u8; 8];
1166        buf[0..2].copy_from_slice(&block_length.to_le_bytes());
1167        buf[2..4].copy_from_slice(&template_id.to_le_bytes());
1168        buf[4..6].copy_from_slice(&schema_id.to_le_bytes());
1169        buf[6..8].copy_from_slice(&version.to_le_bytes());
1170        buf
1171    }
1172
1173    #[rstest]
1174    fn test_decode_ping_valid() {
1175        // Ping: block_length=0, template_id=101, schema_id=3, version=1
1176        let buf = create_header(0, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1177        decode_ping(&buf).unwrap();
1178    }
1179
1180    #[rstest]
1181    fn test_decode_ping_buffer_too_short() {
1182        let buf = [0u8; 4];
1183        let err = decode_ping(&buf).unwrap_err();
1184        assert!(matches!(err, SbeDecodeError::BufferTooShort { .. }));
1185    }
1186
1187    #[rstest]
1188    fn test_decode_ping_schema_mismatch() {
1189        let buf = create_header(0, PING_TEMPLATE_ID, 99, SBE_SCHEMA_VERSION);
1190        let err = decode_ping(&buf).unwrap_err();
1191        assert!(matches!(err, SbeDecodeError::SchemaMismatch { .. }));
1192    }
1193
1194    #[rstest]
1195    fn test_decode_ping_wrong_template() {
1196        let buf = create_header(0, 999, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1197        let err = decode_ping(&buf).unwrap_err();
1198        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(999)));
1199    }
1200
1201    #[rstest]
1202    fn test_decode_server_time_valid() {
1203        // ServerTime: block_length=8, template_id=102, schema_id=3, version=1
1204        let header = create_header(
1205            8,
1206            SERVER_TIME_TEMPLATE_ID,
1207            SBE_SCHEMA_ID,
1208            SBE_SCHEMA_VERSION,
1209        );
1210        let timestamp: i64 = 1734300000000; // Example timestamp
1211
1212        let mut buf = Vec::with_capacity(16);
1213        buf.extend_from_slice(&header);
1214        buf.extend_from_slice(&timestamp.to_le_bytes());
1215
1216        let result = decode_server_time(&buf).unwrap();
1217        assert_eq!(result, timestamp);
1218    }
1219
1220    #[rstest]
1221    fn test_decode_server_time_buffer_too_short() {
1222        // Header only, missing body
1223        let buf = create_header(
1224            8,
1225            SERVER_TIME_TEMPLATE_ID,
1226            SBE_SCHEMA_ID,
1227            SBE_SCHEMA_VERSION,
1228        );
1229        let err = decode_server_time(&buf).unwrap_err();
1230        assert!(matches!(err, SbeDecodeError::BufferTooShort { .. }));
1231    }
1232
1233    #[rstest]
1234    fn test_decode_server_time_wrong_template() {
1235        let header = create_header(8, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1236        let mut buf = Vec::with_capacity(16);
1237        buf.extend_from_slice(&header);
1238        buf.extend_from_slice(&0i64.to_le_bytes());
1239
1240        let err = decode_server_time(&buf).unwrap_err();
1241        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1242    }
1243
1244    // Any version within the schema ID must decode. During a rollout one client sees
1245    // both the older version (un-migrated server) and the newer version; future
1246    // additive bumps must keep decoding too.
1247    #[rstest]
1248    #[case(SBE_SCHEMA_VERSION - 1)]
1249    #[case(SBE_SCHEMA_VERSION)]
1250    #[case(SBE_SCHEMA_VERSION + 1)]
1251    #[case(99)]
1252    fn test_decode_server_time_accepts_any_version(#[case] version: u16) {
1253        let header = create_header(8, SERVER_TIME_TEMPLATE_ID, SBE_SCHEMA_ID, version);
1254        let mut buf = Vec::with_capacity(16);
1255        buf.extend_from_slice(&header);
1256        buf.extend_from_slice(&1_700_000_000_000i64.to_le_bytes());
1257
1258        let result = decode_server_time(&buf).unwrap();
1259        assert_eq!(result, 1_700_000_000_000);
1260    }
1261
1262    fn create_group_header(block_length: u16, count: u32) -> [u8; 6] {
1263        let mut buf = [0u8; 6];
1264        buf[0..2].copy_from_slice(&block_length.to_le_bytes());
1265        buf[2..6].copy_from_slice(&count.to_le_bytes());
1266        buf
1267    }
1268
1269    #[rstest]
1270    fn test_decode_depth_valid() {
1271        // Depth: block_length=10, template_id=200
1272        let header = create_header(10, DEPTH_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1273
1274        let mut buf = Vec::new();
1275        buf.extend_from_slice(&header);
1276
1277        // Block: last_update_id (8) + price_exponent (1) + qty_exponent (1)
1278        let last_update_id: i64 = 123456789;
1279        let price_exponent: i8 = -8;
1280        let qty_exponent: i8 = -8;
1281        buf.extend_from_slice(&last_update_id.to_le_bytes());
1282        buf.push(price_exponent as u8);
1283        buf.push(qty_exponent as u8);
1284
1285        // Bids group: 2 levels
1286        buf.extend_from_slice(&create_group_header(16, 2));
1287        // Bid 1: price=100000000000, qty=50000000
1288        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes());
1289        buf.extend_from_slice(&50_000_000i64.to_le_bytes());
1290        // Bid 2: price=99900000000, qty=30000000
1291        buf.extend_from_slice(&99_900_000_000i64.to_le_bytes());
1292        buf.extend_from_slice(&30_000_000i64.to_le_bytes());
1293
1294        // Asks group: 1 level
1295        buf.extend_from_slice(&create_group_header(16, 1));
1296        // Ask 1: price=100100000000, qty=25000000
1297        buf.extend_from_slice(&100_100_000_000i64.to_le_bytes());
1298        buf.extend_from_slice(&25_000_000i64.to_le_bytes());
1299
1300        let depth = decode_depth(&buf).unwrap();
1301
1302        assert_eq!(depth.last_update_id, 123456789);
1303        assert_eq!(depth.price_exponent, -8);
1304        assert_eq!(depth.qty_exponent, -8);
1305        assert_eq!(depth.bids.len(), 2);
1306        assert_eq!(depth.asks.len(), 1);
1307        assert_eq!(depth.bids[0].price_mantissa, 100_000_000_000);
1308        assert_eq!(depth.bids[0].qty_mantissa, 50_000_000);
1309        assert_eq!(depth.asks[0].price_mantissa, 100_100_000_000);
1310    }
1311
1312    #[rstest]
1313    fn test_decode_depth_empty_book() {
1314        let header = create_header(10, DEPTH_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1315
1316        let mut buf = Vec::new();
1317        buf.extend_from_slice(&header);
1318        buf.extend_from_slice(&0i64.to_le_bytes()); // last_update_id
1319        buf.push(0); // price_exponent
1320        buf.push(0); // qty_exponent
1321
1322        // Empty bids
1323        buf.extend_from_slice(&create_group_header(16, 0));
1324        // Empty asks
1325        buf.extend_from_slice(&create_group_header(16, 0));
1326
1327        let depth = decode_depth(&buf).unwrap();
1328
1329        assert!(depth.bids.is_empty());
1330        assert!(depth.asks.is_empty());
1331    }
1332
1333    #[rstest]
1334    fn test_decode_trades_valid() {
1335        // Trades: block_length=2, template_id=201
1336        let header = create_header(2, TRADES_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1337
1338        let mut buf = Vec::new();
1339        buf.extend_from_slice(&header);
1340
1341        // Block: price_exponent (1) + qty_exponent (1)
1342        let price_exponent: i8 = -8;
1343        let qty_exponent: i8 = -8;
1344        buf.push(price_exponent as u8);
1345        buf.push(qty_exponent as u8);
1346
1347        // Trades group: 1 trade (42 bytes each)
1348        buf.extend_from_slice(&create_group_header(42, 1));
1349
1350        // Trade: id(8) + price(8) + qty(8) + quoteQty(8) + time(8) + isBuyerMaker(1) + isBestMatch(1)
1351        let trade_id: i64 = 999;
1352        let price: i64 = 100_000_000_000;
1353        let qty: i64 = 10_000_000;
1354        let quote_qty: i64 = 1_000_000_000_000;
1355        let time: i64 = 1734300000000;
1356        let is_buyer_maker: u8 = 1; // true
1357        let is_best_match: u8 = 1; // true
1358
1359        buf.extend_from_slice(&trade_id.to_le_bytes());
1360        buf.extend_from_slice(&price.to_le_bytes());
1361        buf.extend_from_slice(&qty.to_le_bytes());
1362        buf.extend_from_slice(&quote_qty.to_le_bytes());
1363        buf.extend_from_slice(&time.to_le_bytes());
1364        buf.push(is_buyer_maker);
1365        buf.push(is_best_match);
1366
1367        let trades = decode_trades(&buf).unwrap();
1368
1369        assert_eq!(trades.price_exponent, -8);
1370        assert_eq!(trades.qty_exponent, -8);
1371        assert_eq!(trades.trades.len(), 1);
1372        assert_eq!(trades.trades[0].id, 999);
1373        assert_eq!(trades.trades[0].price_mantissa, 100_000_000_000);
1374        assert!(trades.trades[0].is_buyer_maker);
1375        assert!(trades.trades[0].is_best_match);
1376    }
1377
1378    #[rstest]
1379    fn test_decode_trades_empty() {
1380        let header = create_header(2, TRADES_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1381
1382        let mut buf = Vec::new();
1383        buf.extend_from_slice(&header);
1384        buf.push(0); // price_exponent
1385        buf.push(0); // qty_exponent
1386
1387        // Empty trades group
1388        buf.extend_from_slice(&create_group_header(42, 0));
1389
1390        let trades = decode_trades(&buf).unwrap();
1391
1392        assert!(trades.trades.is_empty());
1393    }
1394
1395    #[rstest]
1396    fn test_decode_agg_trades_preserves_all_fields() {
1397        let header = create_header(2, AGG_TRADES_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1398        let mut buf = Vec::new();
1399        buf.extend_from_slice(&header);
1400        buf.push((-7_i8) as u8);
1401        buf.push((-5_i8) as u8);
1402        buf.extend_from_slice(&create_group_header(50, 1));
1403        buf.extend_from_slice(&101_i64.to_le_bytes());
1404        buf.extend_from_slice(&123_456_789_i64.to_le_bytes());
1405        buf.extend_from_slice(&765_432_i64.to_le_bytes());
1406        buf.extend_from_slice(&201_i64.to_le_bytes());
1407        buf.extend_from_slice(&207_i64.to_le_bytes());
1408        buf.extend_from_slice(&1_700_000_000_123_i64.to_le_bytes());
1409        buf.push(1);
1410        buf.push(0);
1411
1412        let trades = decode_agg_trades(&buf).unwrap();
1413
1414        assert_eq!(trades.price_exponent, -7);
1415        assert_eq!(trades.qty_exponent, -5);
1416        assert_eq!(trades.trades.len(), 1);
1417        assert_eq!(trades.trades[0].id, 101);
1418        assert_eq!(trades.trades[0].price_mantissa, 123_456_789);
1419        assert_eq!(trades.trades[0].qty_mantissa, 765_432);
1420        assert_eq!(trades.trades[0].first_trade_id, 201);
1421        assert_eq!(trades.trades[0].last_trade_id, 207);
1422        assert_eq!(trades.trades[0].time, 1_700_000_000_123);
1423        assert!(trades.trades[0].is_buyer_maker);
1424        assert!(!trades.trades[0].is_best_match);
1425    }
1426
1427    #[rstest]
1428    fn test_decode_agg_trades_rejects_wrong_template() {
1429        let header = create_header(2, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1430        let mut buf = Vec::new();
1431        buf.extend_from_slice(&header);
1432        buf.extend_from_slice(&[0_u8; 2]);
1433
1434        let error = decode_agg_trades(&buf).unwrap_err();
1435
1436        assert!(matches!(error, SbeDecodeError::UnknownTemplateId(101)));
1437    }
1438
1439    #[rstest]
1440    fn test_decode_depth_wrong_template() {
1441        let header = create_header(10, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1442
1443        let mut buf = Vec::new();
1444        buf.extend_from_slice(&header);
1445        buf.extend_from_slice(&[0u8; 10]); // dummy block
1446
1447        let err = decode_depth(&buf).unwrap_err();
1448        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1449    }
1450
1451    #[rstest]
1452    fn test_decode_trades_wrong_template() {
1453        let header = create_header(2, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1454
1455        let mut buf = Vec::new();
1456        buf.extend_from_slice(&header);
1457        buf.extend_from_slice(&[0u8; 2]); // dummy block
1458
1459        let err = decode_trades(&buf).unwrap_err();
1460        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1461    }
1462
1463    fn write_var_string(buf: &mut Vec<u8>, s: &str) {
1464        buf.push(s.len() as u8);
1465        buf.extend_from_slice(s.as_bytes());
1466    }
1467
1468    /// Builds an `orderResponse` SBE buffer with the supplied `block_length`.
1469    /// Pads the fixed block with zeros up to `block_length - 1`, then writes
1470    /// `trailing_byte` as the final byte of the fixed block. For pre-v4
1471    /// layouts (`block_length <= 153`) the trailer is zero-padding only and
1472    /// `trailing_byte` is ignored.
1473    fn build_order_response_buffer(block_length: u16, trailing_byte: u8) -> Vec<u8> {
1474        let header = create_header(
1475            block_length,
1476            ORDER_TEMPLATE_ID,
1477            SBE_SCHEMA_ID,
1478            SBE_SCHEMA_VERSION,
1479        );
1480
1481        let mut buf = Vec::new();
1482        buf.extend_from_slice(&header);
1483
1484        buf.push((-8i8) as u8); // price_exponent
1485        buf.push((-8i8) as u8); // qty_exponent
1486        buf.extend_from_slice(&12345i64.to_le_bytes()); // order_id
1487        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1488        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1489        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1490        buf.extend_from_slice(&5_000_000i64.to_le_bytes()); // executed_qty
1491        buf.extend_from_slice(&500_000_000i64.to_le_bytes()); // cumulative_quote_qty
1492        buf.push(1); // status (NEW)
1493        buf.push(1); // time_in_force (GTC)
1494        buf.push(1); // order_type (LIMIT)
1495        buf.push(1); // side (BUY)
1496        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1497        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1498        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1499        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // update_time
1500        buf.push(1); // is_working (true)
1501        buf.extend_from_slice(&1734300000500i64.to_le_bytes()); // working_time
1502        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty
1503        buf.push(0); // self_trade_prevention_mode
1504
1505        let block_end = HEADER_LENGTH + block_length as usize;
1506        // Pad up to the last byte of the fixed block, then write the trailing byte.
1507        while buf.len() < block_end.saturating_sub(1) {
1508            buf.push(0);
1509        }
1510
1511        if buf.len() < block_end {
1512            buf.push(trailing_byte);
1513        }
1514
1515        write_var_string(&mut buf, "BTCUSDT");
1516        write_var_string(&mut buf, "my-order-123");
1517        buf
1518    }
1519
1520    #[rstest]
1521    #[case::pre_v4_no_expiry_reason(153, 0x00, None)]
1522    #[case::v4_null_sentinel(163, 0xFF, None)]
1523    #[case::v4_captures_value(163, 0x05, Some(0x05))]
1524    fn test_decode_order_expiry_reason(
1525        #[case] block_length: u16,
1526        #[case] trailing_byte: u8,
1527        #[case] expected: Option<u8>,
1528    ) {
1529        let buf = build_order_response_buffer(block_length, trailing_byte);
1530        let order = decode_order(&buf).unwrap();
1531        assert_eq!(order.expiry_reason, expected);
1532        // Symbol and client_order_id parse correctly only when the cursor
1533        // advances exactly to the end of the fixed block.
1534        assert_eq!(order.symbol, "BTCUSDT");
1535        assert_eq!(order.client_order_id, "my-order-123");
1536    }
1537
1538    #[rstest]
1539    fn test_decode_order_valid() {
1540        let header = create_header(
1541            ORDER_BLOCK_LENGTH as u16,
1542            ORDER_TEMPLATE_ID,
1543            SBE_SCHEMA_ID,
1544            SBE_SCHEMA_VERSION,
1545        );
1546
1547        let mut buf = Vec::new();
1548        buf.extend_from_slice(&header);
1549
1550        // Fixed block (153 bytes)
1551        buf.push((-8i8) as u8); // price_exponent
1552        buf.push((-8i8) as u8); // qty_exponent
1553        buf.extend_from_slice(&12345i64.to_le_bytes()); // order_id
1554        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1555        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1556        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty_mantissa
1557        buf.extend_from_slice(&5_000_000i64.to_le_bytes()); // executed_qty_mantissa
1558        buf.extend_from_slice(&500_000_000i64.to_le_bytes()); // cummulative_quote_qty_mantissa
1559        buf.push(1); // status (NEW)
1560        buf.push(1); // time_in_force (GTC)
1561        buf.push(1); // order_type (LIMIT)
1562        buf.push(1); // side (BUY)
1563        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1564        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1565        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1566        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // update_time
1567        buf.push(1); // is_working (true)
1568        buf.extend_from_slice(&1734300000500i64.to_le_bytes()); // working_time
1569        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty_mantissa
1570        buf.push(0); // self_trade_prevention_mode
1571
1572        // Pad to 153 bytes
1573        while buf.len() < 8 + ORDER_BLOCK_LENGTH {
1574            buf.push(0);
1575        }
1576
1577        write_var_string(&mut buf, "BTCUSDT");
1578        write_var_string(&mut buf, "my-order-123");
1579
1580        let order = decode_order(&buf).unwrap();
1581
1582        assert_eq!(order.order_id, 12345);
1583        assert!(order.order_list_id.is_none());
1584        assert_eq!(order.price_exponent, -8);
1585        assert_eq!(order.price_mantissa, 100_000_000_000);
1586        assert!(order.stop_price_mantissa.is_none());
1587        assert!(order.iceberg_qty_mantissa.is_none());
1588        assert!(order.is_working);
1589        assert_eq!(order.working_time, Some(1734300000500));
1590        assert_eq!(order.symbol, "BTCUSDT");
1591        assert_eq!(order.client_order_id, "my-order-123");
1592    }
1593
1594    #[rstest]
1595    fn test_decode_order_future_block_length() {
1596        // Verify the dynamic skip handles a future block_length larger than v1.
1597        const FUTURE_BLOCK_LENGTH: u16 = ORDER_BLOCK_LENGTH as u16 + 4;
1598        let header = create_header(
1599            FUTURE_BLOCK_LENGTH,
1600            ORDER_TEMPLATE_ID,
1601            SBE_SCHEMA_ID,
1602            SBE_SCHEMA_VERSION,
1603        );
1604
1605        let mut buf = Vec::new();
1606        buf.extend_from_slice(&header);
1607
1608        buf.push((-8i8) as u8); // price_exponent
1609        buf.push((-8i8) as u8); // qty_exponent
1610        buf.extend_from_slice(&12345i64.to_le_bytes()); // order_id
1611        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1612        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1613        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1614        buf.extend_from_slice(&5_000_000i64.to_le_bytes()); // executed_qty
1615        buf.extend_from_slice(&500_000_000i64.to_le_bytes()); // cumulative_quote_qty
1616        buf.push(1); // status (NEW)
1617        buf.push(1); // time_in_force (GTC)
1618        buf.push(1); // order_type (LIMIT)
1619        buf.push(1); // side (BUY)
1620        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1621        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1622        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1623        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // update_time
1624        buf.push(1); // is_working (true)
1625        buf.extend_from_slice(&1734300000500i64.to_le_bytes()); // working_time
1626        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty
1627        buf.push(0); // self_trade_prevention_mode
1628
1629        while buf.len() < 8 + FUTURE_BLOCK_LENGTH as usize {
1630            buf.push(0); // Pad for hypothetical future fields
1631        }
1632
1633        write_var_string(&mut buf, "ETHUSDT");
1634        write_var_string(&mut buf, "order-future");
1635
1636        let order = decode_order(&buf).unwrap();
1637
1638        assert_eq!(order.order_id, 12345);
1639        assert_eq!(order.symbol, "ETHUSDT");
1640        assert_eq!(order.client_order_id, "order-future");
1641    }
1642
1643    #[rstest]
1644    fn test_decode_orders_multiple() {
1645        // This test verifies cursor advances correctly through multiple orders
1646        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1647
1648        let mut buf = Vec::new();
1649        buf.extend_from_slice(&header);
1650
1651        // Group header: block_length=162, count=2
1652        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_MIN_BLOCK_LENGTH, 2));
1653
1654        // Order 1
1655        let order1_start = buf.len();
1656        buf.push((-8i8) as u8); // price_exponent
1657        buf.push((-8i8) as u8); // qty_exponent
1658        buf.extend_from_slice(&1001i64.to_le_bytes()); // order_id
1659        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1660        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1661        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1662        buf.extend_from_slice(&0i64.to_le_bytes()); // executed_qty
1663        buf.extend_from_slice(&0i64.to_le_bytes()); // cummulative_quote_qty
1664        buf.push(1); // status
1665        buf.push(1); // time_in_force
1666        buf.push(1); // order_type
1667        buf.push(1); // side
1668        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1669        buf.extend_from_slice(&[0u8; 16]); // trailing_delta + trailing_time
1670        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1671        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1672        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // update_time
1673        buf.push(1); // is_working
1674        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // working_time
1675        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty
1676
1677        // Pad to 162 bytes from order start
1678        while buf.len() - order1_start < ORDERS_GROUP_MIN_BLOCK_LENGTH as usize {
1679            buf.push(0);
1680        }
1681        write_var_string(&mut buf, "BTCUSDT");
1682        write_var_string(&mut buf, "order-1");
1683
1684        // Order 2
1685        let order2_start = buf.len();
1686        buf.push((-8i8) as u8); // price_exponent
1687        buf.push((-8i8) as u8); // qty_exponent
1688        buf.extend_from_slice(&2002i64.to_le_bytes()); // order_id
1689        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1690        buf.extend_from_slice(&200_000_000_000i64.to_le_bytes()); // price_mantissa
1691        buf.extend_from_slice(&20_000_000i64.to_le_bytes()); // orig_qty
1692        buf.extend_from_slice(&0i64.to_le_bytes()); // executed_qty
1693        buf.extend_from_slice(&0i64.to_le_bytes()); // cummulative_quote_qty
1694        buf.push(1); // status
1695        buf.push(1); // time_in_force
1696        buf.push(1); // order_type
1697        buf.push(2); // side (SELL)
1698        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1699        buf.extend_from_slice(&[0u8; 16]); // trailing_delta + trailing_time
1700        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1701        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // time
1702        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // update_time
1703        buf.push(1); // is_working
1704        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // working_time
1705        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty
1706
1707        while buf.len() - order2_start < ORDERS_GROUP_MIN_BLOCK_LENGTH as usize {
1708            buf.push(0);
1709        }
1710        write_var_string(&mut buf, "ETHUSDT");
1711        write_var_string(&mut buf, "order-2");
1712
1713        let orders = decode_orders(&buf).unwrap();
1714
1715        assert_eq!(orders.len(), 2);
1716        assert_eq!(orders[0].order_id, 1001);
1717        assert_eq!(orders[0].symbol, "BTCUSDT");
1718        assert_eq!(orders[0].client_order_id, "order-1");
1719        assert_eq!(orders[0].price_mantissa, 100_000_000_000);
1720
1721        assert_eq!(orders[1].order_id, 2002);
1722        assert_eq!(orders[1].symbol, "ETHUSDT");
1723        assert_eq!(orders[1].client_order_id, "order-2");
1724        assert_eq!(orders[1].price_mantissa, 200_000_000_000);
1725    }
1726
1727    #[rstest]
1728    fn test_decode_orders_v4_trailing_expiry_reason() {
1729        // Schema 3:4 appends a 1-byte expiryReason to the orders group fixed block,
1730        // bumping its length 162 -> 163. The decoder must read it via the runtime
1731        // group block_length so the symbol var-string starts at the right offset.
1732        const V4_BLOCK_LENGTH: u16 = 163;
1733        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1734
1735        let mut buf = Vec::new();
1736        buf.extend_from_slice(&header);
1737        buf.extend_from_slice(&create_group_header(V4_BLOCK_LENGTH, 1));
1738
1739        let order_start = buf.len();
1740        buf.extend_from_slice(&[0u8; ORDERS_GROUP_MIN_BLOCK_LENGTH as usize]);
1741        buf.push(0xFF); // Sentinel for the new expiryReason byte (null/absent)
1742        assert_eq!(buf.len() - order_start, V4_BLOCK_LENGTH as usize);
1743
1744        write_var_string(&mut buf, "BTCUSDT");
1745        write_var_string(&mut buf, "v4-order");
1746
1747        let orders = decode_orders(&buf).unwrap();
1748
1749        assert_eq!(orders.len(), 1);
1750        assert_eq!(orders[0].symbol, "BTCUSDT");
1751        assert_eq!(orders[0].client_order_id, "v4-order");
1752        assert!(orders[0].expiry_reason.is_none());
1753    }
1754
1755    #[rstest]
1756    fn test_decode_orders_pre_v4_block_returns_no_expiry_reason() {
1757        // Schema 3:3 block_length is 162 (no expiryReason byte). The decoder
1758        // must surface `expiry_reason = None` regardless of the trailing
1759        // padding bytes inside the fixed block.
1760        const PRE_V4_BLOCK_LENGTH: u16 = ORDERS_GROUP_MIN_BLOCK_LENGTH;
1761        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1762
1763        let mut buf = Vec::new();
1764        buf.extend_from_slice(&header);
1765        buf.extend_from_slice(&create_group_header(PRE_V4_BLOCK_LENGTH, 1));
1766
1767        // Fill the fixed block with non-zero, non-0xff padding so the previous
1768        // bug (returning that last byte as expiry_reason) would surface.
1769        buf.extend_from_slice(&[0xAAu8; PRE_V4_BLOCK_LENGTH as usize]);
1770
1771        write_var_string(&mut buf, "BTCUSDT");
1772        write_var_string(&mut buf, "pre-v4-order");
1773
1774        let orders = decode_orders(&buf).unwrap();
1775        assert!(orders[0].expiry_reason.is_none());
1776    }
1777
1778    #[rstest]
1779    fn test_decode_orders_v4_captures_expiry_reason_value() {
1780        // Same layout as the null case, but the trailing byte carries a real
1781        // expiryReason value (0x05 = UnfilledIocQuantityExpired). The decoder
1782        // must surface it on the parsed `BinanceOrderResponse`.
1783        const V4_BLOCK_LENGTH: u16 = 163;
1784        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1785
1786        let mut buf = Vec::new();
1787        buf.extend_from_slice(&header);
1788        buf.extend_from_slice(&create_group_header(V4_BLOCK_LENGTH, 1));
1789
1790        buf.extend_from_slice(&[0u8; ORDERS_GROUP_MIN_BLOCK_LENGTH as usize]);
1791        buf.push(0x05);
1792
1793        write_var_string(&mut buf, "BTCUSDT");
1794        write_var_string(&mut buf, "v4-expired");
1795
1796        let orders = decode_orders(&buf).unwrap();
1797        assert_eq!(orders[0].expiry_reason, Some(0x05));
1798    }
1799
1800    #[rstest]
1801    fn test_decode_orders_empty() {
1802        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1803
1804        let mut buf = Vec::new();
1805        buf.extend_from_slice(&header);
1806        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_MIN_BLOCK_LENGTH, 0));
1807
1808        let orders = decode_orders(&buf).unwrap();
1809        assert!(orders.is_empty());
1810    }
1811
1812    #[rstest]
1813    fn test_decode_orders_truncated_var_string() {
1814        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1815
1816        let mut buf = Vec::new();
1817        buf.extend_from_slice(&header);
1818        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_MIN_BLOCK_LENGTH, 1));
1819
1820        // Pad fixed block to 162 bytes
1821        buf.extend_from_slice(&[0u8; ORDERS_GROUP_MIN_BLOCK_LENGTH as usize]);
1822
1823        // Symbol length says 7 bytes but we only provide 3
1824        buf.push(7); // Length prefix claims "BTCUSDT" (7 chars)
1825        buf.extend_from_slice(b"BTC"); // Only 3 bytes - truncated
1826
1827        let err = decode_orders(&buf).unwrap_err();
1828        assert!(matches!(err, SbeDecodeError::BufferTooShort { .. }));
1829    }
1830
1831    #[rstest]
1832    fn test_decode_orders_invalid_utf8() {
1833        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1834
1835        let mut buf = Vec::new();
1836        buf.extend_from_slice(&header);
1837        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_MIN_BLOCK_LENGTH, 1));
1838
1839        buf.extend_from_slice(&[0u8; ORDERS_GROUP_MIN_BLOCK_LENGTH as usize]);
1840
1841        // Invalid UTF-8 sequence
1842        buf.push(4);
1843        buf.extend_from_slice(&[0xFF, 0xFE, 0x00, 0x01]);
1844
1845        let err = decode_orders(&buf).unwrap_err();
1846        assert!(matches!(err, SbeDecodeError::InvalidUtf8));
1847    }
1848
1849    #[rstest]
1850    fn test_decode_cancel_order_valid() {
1851        let header = create_header(
1852            CANCEL_ORDER_BLOCK_LENGTH as u16,
1853            CANCEL_ORDER_TEMPLATE_ID,
1854            SBE_SCHEMA_ID,
1855            SBE_SCHEMA_VERSION,
1856        );
1857
1858        let mut buf = Vec::new();
1859        buf.extend_from_slice(&header);
1860
1861        buf.push((-8i8) as u8); // price_exponent
1862        buf.push((-8i8) as u8); // qty_exponent
1863        buf.extend_from_slice(&99999i64.to_le_bytes()); // order_id
1864        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1865        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // transact_time
1866        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1867        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1868        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // executed_qty
1869        buf.extend_from_slice(&1_000_000_000i64.to_le_bytes()); // cummulative_quote_qty
1870        buf.push(4); // status (CANCELED)
1871        buf.push(1); // time_in_force
1872        buf.push(1); // order_type
1873        buf.push(1); // side
1874        buf.push(0); // self_trade_prevention_mode
1875
1876        // Pad to block length
1877        while buf.len() < 8 + CANCEL_ORDER_BLOCK_LENGTH {
1878            buf.push(0);
1879        }
1880
1881        write_var_string(&mut buf, "BTCUSDT");
1882        write_var_string(&mut buf, "orig-client-id");
1883        write_var_string(&mut buf, "new-client-id");
1884
1885        let cancel = decode_cancel_order(&buf).unwrap();
1886
1887        assert_eq!(cancel.order_id, 99999);
1888        assert!(cancel.order_list_id.is_none());
1889        assert_eq!(cancel.symbol, "BTCUSDT");
1890        assert_eq!(cancel.orig_client_order_id, "orig-client-id");
1891        assert_eq!(cancel.client_order_id, "new-client-id");
1892    }
1893
1894    #[rstest]
1895    fn test_decode_cancel_order_future_block_length() {
1896        // Verify the dynamic skip handles a future block_length larger than v1.
1897        const FUTURE_BLOCK_LENGTH: u16 = CANCEL_ORDER_BLOCK_LENGTH as u16 + 4;
1898        let header = create_header(
1899            FUTURE_BLOCK_LENGTH,
1900            CANCEL_ORDER_TEMPLATE_ID,
1901            SBE_SCHEMA_ID,
1902            SBE_SCHEMA_VERSION,
1903        );
1904
1905        let mut buf = Vec::new();
1906        buf.extend_from_slice(&header);
1907
1908        buf.push((-8i8) as u8); // price_exponent
1909        buf.push((-8i8) as u8); // qty_exponent
1910        buf.extend_from_slice(&99999i64.to_le_bytes()); // order_id
1911        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1912        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // transact_time
1913        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1914        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1915        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // executed_qty
1916        buf.extend_from_slice(&1_000_000_000i64.to_le_bytes()); // cumulative_quote_qty
1917        buf.push(4); // status (CANCELED)
1918        buf.push(1); // time_in_force
1919        buf.push(1); // order_type
1920        buf.push(1); // side
1921        buf.push(0); // self_trade_prevention_mode
1922
1923        while buf.len() < 8 + FUTURE_BLOCK_LENGTH as usize {
1924            buf.push(0); // Pad for hypothetical future fields
1925        }
1926
1927        write_var_string(&mut buf, "BTCUSDT");
1928        write_var_string(&mut buf, "orig-id");
1929        write_var_string(&mut buf, "new-id");
1930
1931        let cancel = decode_cancel_order(&buf).unwrap();
1932
1933        assert_eq!(cancel.order_id, 99999);
1934        assert_eq!(cancel.symbol, "BTCUSDT");
1935        assert_eq!(cancel.orig_client_order_id, "orig-id");
1936        assert_eq!(cancel.client_order_id, "new-id");
1937    }
1938
1939    #[rstest]
1940    fn test_decode_account_with_balances() {
1941        let header = create_header(
1942            ACCOUNT_BLOCK_LENGTH as u16,
1943            ACCOUNT_TEMPLATE_ID,
1944            SBE_SCHEMA_ID,
1945            SBE_SCHEMA_VERSION,
1946        );
1947
1948        let mut buf = Vec::new();
1949        buf.extend_from_slice(&header);
1950
1951        // Fixed block (64 bytes)
1952        buf.push((-8i8) as u8); // commission_exponent
1953        buf.extend_from_slice(&100_000i64.to_le_bytes()); // maker_commission
1954        buf.extend_from_slice(&100_000i64.to_le_bytes()); // taker_commission
1955        buf.extend_from_slice(&0i64.to_le_bytes()); // buyer_commission
1956        buf.extend_from_slice(&0i64.to_le_bytes()); // seller_commission
1957        buf.push(1); // can_trade
1958        buf.push(1); // can_withdraw
1959        buf.push(1); // can_deposit
1960        buf.push(0); // brokered
1961        buf.push(0); // require_self_trade_prevention
1962        buf.push(0); // prevent_sor
1963        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // update_time
1964        buf.push(1); // account_type (SPOT)
1965
1966        // Pad to 64 bytes
1967        while buf.len() < 8 + ACCOUNT_BLOCK_LENGTH {
1968            buf.push(0);
1969        }
1970
1971        // Balances group: 2 balances
1972        buf.extend_from_slice(&create_group_header(BALANCE_BLOCK_LENGTH, 2));
1973
1974        // Balance 1: BTC
1975        buf.push((-8i8) as u8); // exponent
1976        buf.extend_from_slice(&100_000_000i64.to_le_bytes()); // free (1.0 BTC)
1977        buf.extend_from_slice(&50_000_000i64.to_le_bytes()); // locked (0.5 BTC)
1978        write_var_string(&mut buf, "BTC");
1979
1980        // Balance 2: USDT
1981        buf.push((-8i8) as u8); // exponent
1982        buf.extend_from_slice(&1_000_000_000_000i64.to_le_bytes()); // free (10000 USDT)
1983        buf.extend_from_slice(&0i64.to_le_bytes()); // locked
1984        write_var_string(&mut buf, "USDT");
1985
1986        let account = decode_account(&buf).unwrap();
1987
1988        assert!(account.can_trade);
1989        assert!(account.can_withdraw);
1990        assert!(account.can_deposit);
1991        assert_eq!(account.balances.len(), 2);
1992        assert_eq!(account.balances[0].asset, "BTC");
1993        assert_eq!(account.balances[0].free_mantissa, 100_000_000);
1994        assert_eq!(account.balances[0].locked_mantissa, 50_000_000);
1995        assert_eq!(account.balances[1].asset, "USDT");
1996        assert_eq!(account.balances[1].free_mantissa, 1_000_000_000_000);
1997    }
1998
1999    #[rstest]
2000    fn test_decode_account_empty_balances() {
2001        let header = create_header(
2002            ACCOUNT_BLOCK_LENGTH as u16,
2003            ACCOUNT_TEMPLATE_ID,
2004            SBE_SCHEMA_ID,
2005            SBE_SCHEMA_VERSION,
2006        );
2007
2008        let mut buf = Vec::new();
2009        buf.extend_from_slice(&header);
2010
2011        // Minimal fixed block
2012        buf.push((-8i8) as u8);
2013        buf.extend_from_slice(&[0u8; 63]); // Rest of fixed block
2014
2015        // Empty balances group
2016        buf.extend_from_slice(&create_group_header(BALANCE_BLOCK_LENGTH, 0));
2017
2018        let account = decode_account(&buf).unwrap();
2019        assert!(account.balances.is_empty());
2020    }
2021
2022    #[rstest]
2023    fn test_decode_account_trades_multiple() {
2024        let header = create_header(
2025            0,
2026            ACCOUNT_TRADES_TEMPLATE_ID,
2027            SBE_SCHEMA_ID,
2028            SBE_SCHEMA_VERSION,
2029        );
2030
2031        let mut buf = Vec::new();
2032        buf.extend_from_slice(&header);
2033
2034        // Group header: 2 trades
2035        buf.extend_from_slice(&create_group_header(ACCOUNT_TRADE_BLOCK_LENGTH, 2));
2036
2037        // Trade 1
2038        buf.push((-8i8) as u8); // price_exponent
2039        buf.push((-8i8) as u8); // qty_exponent
2040        buf.push((-8i8) as u8); // commission_exponent
2041        buf.extend_from_slice(&1001i64.to_le_bytes()); // id
2042        buf.extend_from_slice(&5001i64.to_le_bytes()); // order_id
2043        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
2044        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price
2045        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // qty
2046        buf.extend_from_slice(&1_000_000_000_000i64.to_le_bytes()); // quote_qty
2047        buf.extend_from_slice(&100_000i64.to_le_bytes()); // commission
2048        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
2049        buf.push(1); // is_buyer
2050        buf.push(0); // is_maker
2051        buf.push(1); // is_best_match
2052        write_var_string(&mut buf, "BTCUSDT");
2053        write_var_string(&mut buf, "BNB");
2054
2055        // Trade 2
2056        buf.push((-8i8) as u8);
2057        buf.push((-8i8) as u8);
2058        buf.push((-8i8) as u8);
2059        buf.extend_from_slice(&1002i64.to_le_bytes());
2060        buf.extend_from_slice(&5002i64.to_le_bytes());
2061        buf.extend_from_slice(&i64::MIN.to_le_bytes());
2062        buf.extend_from_slice(&200_000_000_000i64.to_le_bytes());
2063        buf.extend_from_slice(&5_000_000i64.to_le_bytes());
2064        buf.extend_from_slice(&1_000_000_000_000i64.to_le_bytes());
2065        buf.extend_from_slice(&50_000i64.to_le_bytes());
2066        buf.extend_from_slice(&1734300001000i64.to_le_bytes());
2067        buf.push(0); // is_buyer (false = seller)
2068        buf.push(1); // is_maker
2069        buf.push(1); // is_best_match
2070        write_var_string(&mut buf, "ETHUSDT");
2071        write_var_string(&mut buf, "USDT");
2072
2073        let trades = decode_account_trades(&buf).unwrap();
2074
2075        assert_eq!(trades.len(), 2);
2076        assert_eq!(trades[0].id, 1001);
2077        assert_eq!(trades[0].order_id, 5001);
2078        assert!(trades[0].order_list_id.is_none());
2079        assert_eq!(trades[0].symbol, "BTCUSDT");
2080        assert_eq!(trades[0].commission_asset, "BNB");
2081        assert!(trades[0].is_buyer);
2082        assert!(!trades[0].is_maker);
2083
2084        assert_eq!(trades[1].id, 1002);
2085        assert_eq!(trades[1].symbol, "ETHUSDT");
2086        assert_eq!(trades[1].commission_asset, "USDT");
2087        assert!(!trades[1].is_buyer);
2088        assert!(trades[1].is_maker);
2089    }
2090
2091    #[rstest]
2092    fn test_decode_account_trades_empty() {
2093        let header = create_header(
2094            0,
2095            ACCOUNT_TRADES_TEMPLATE_ID,
2096            SBE_SCHEMA_ID,
2097            SBE_SCHEMA_VERSION,
2098        );
2099
2100        let mut buf = Vec::new();
2101        buf.extend_from_slice(&header);
2102        buf.extend_from_slice(&create_group_header(ACCOUNT_TRADE_BLOCK_LENGTH, 0));
2103
2104        let trades = decode_account_trades(&buf).unwrap();
2105        assert!(trades.is_empty());
2106    }
2107
2108    #[rstest]
2109    fn test_decode_exchange_info_single_symbol() {
2110        let header = create_header(
2111            0,
2112            EXCHANGE_INFO_TEMPLATE_ID,
2113            SBE_SCHEMA_ID,
2114            SBE_SCHEMA_VERSION,
2115        );
2116
2117        let mut buf = Vec::new();
2118        buf.extend_from_slice(&header);
2119
2120        // Empty rate_limits group
2121        buf.extend_from_slice(&create_group_header(11, 0));
2122
2123        // Empty exchange_filters group
2124        buf.extend_from_slice(&create_group_header(0, 0));
2125
2126        // Symbols group: 1 symbol with block_length=19
2127        buf.extend_from_slice(&create_group_header(SYMBOL_BLOCK_LENGTH as u16, 1));
2128
2129        // Fixed block (19 bytes)
2130        buf.push(0); // status (Trading)
2131        buf.push(8); // base_asset_precision
2132        buf.push(8); // quote_asset_precision
2133        buf.push(8); // base_commission_precision
2134        buf.push(8); // quote_commission_precision
2135        buf.extend_from_slice(&0b0000_0111u16.to_le_bytes()); // order_types (MARKET|LIMIT|STOP_LOSS)
2136        buf.push(1); // iceberg_allowed (True)
2137        buf.push(1); // oco_allowed (True)
2138        buf.push(0); // oto_allowed (False)
2139        buf.push(1); // quote_order_qty_market_allowed (True)
2140        buf.push(1); // allow_trailing_stop (True)
2141        buf.push(1); // cancel_replace_allowed (True)
2142        buf.push(0); // amend_allowed (False)
2143        buf.push(1); // is_spot_trading_allowed (True)
2144        buf.push(0); // is_margin_trading_allowed (False)
2145        buf.push(0); // default_self_trade_prevention_mode
2146        buf.push(0); // allowed_self_trade_prevention_modes
2147        buf.push(0); // peg_instructions_allowed
2148
2149        // Filters nested group: 0 filters (SBE binary filters are skipped)
2150        buf.extend_from_slice(&create_group_header(0, 0));
2151
2152        // Permission sets nested group: 1 set with 1 permission
2153        buf.extend_from_slice(&create_group_header(0, 1));
2154        buf.extend_from_slice(&create_group_header(0, 1));
2155        write_var_string(&mut buf, "SPOT");
2156
2157        // Variable-length strings
2158        write_var_string(&mut buf, "BTCUSDT");
2159        write_var_string(&mut buf, "BTC");
2160        write_var_string(&mut buf, "USDT");
2161
2162        let info = decode_exchange_info(&buf).unwrap();
2163
2164        assert_eq!(info.symbols.len(), 1);
2165        let symbol = &info.symbols[0];
2166        assert_eq!(symbol.symbol, "BTCUSDT");
2167        assert_eq!(symbol.base_asset, "BTC");
2168        assert_eq!(symbol.quote_asset, "USDT");
2169        assert_eq!(symbol.base_asset_precision, 8);
2170        assert_eq!(symbol.quote_asset_precision, 8);
2171        assert_eq!(symbol.status, 0); // Trading
2172        assert_eq!(symbol.order_types, 0b0000_0111);
2173        assert!(symbol.iceberg_allowed);
2174        assert!(symbol.oco_allowed);
2175        assert!(!symbol.oto_allowed);
2176        assert!(symbol.quote_order_qty_market_allowed);
2177        assert!(symbol.allow_trailing_stop);
2178        assert!(symbol.cancel_replace_allowed);
2179        assert!(!symbol.amend_allowed);
2180        assert!(symbol.is_spot_trading_allowed);
2181        assert!(!symbol.is_margin_trading_allowed);
2182        assert!(symbol.filters.price_filter.is_none()); // No filters in test data
2183        assert!(symbol.filters.lot_size_filter.is_none());
2184        assert_eq!(symbol.permissions.len(), 1);
2185        assert_eq!(symbol.permissions[0], vec!["SPOT"]);
2186    }
2187
2188    #[rstest]
2189    fn test_decode_exchange_info_empty() {
2190        let header = create_header(
2191            0,
2192            EXCHANGE_INFO_TEMPLATE_ID,
2193            SBE_SCHEMA_ID,
2194            SBE_SCHEMA_VERSION,
2195        );
2196
2197        let mut buf = Vec::new();
2198        buf.extend_from_slice(&header);
2199
2200        // Empty rate_limits group
2201        buf.extend_from_slice(&create_group_header(11, 0));
2202
2203        // Empty exchange_filters group
2204        buf.extend_from_slice(&create_group_header(0, 0));
2205
2206        // Empty symbols group
2207        buf.extend_from_slice(&create_group_header(SYMBOL_BLOCK_LENGTH as u16, 0));
2208
2209        let info = decode_exchange_info(&buf).unwrap();
2210        assert!(info.symbols.is_empty());
2211    }
2212
2213    #[rstest]
2214    fn test_decode_exchange_info_wrong_template() {
2215        let header = create_header(0, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
2216
2217        let mut buf = Vec::new();
2218        buf.extend_from_slice(&header);
2219
2220        let err = decode_exchange_info(&buf).unwrap_err();
2221        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
2222    }
2223
2224    #[rstest]
2225    fn test_decode_exchange_info_multiple_symbols() {
2226        let header = create_header(
2227            0,
2228            EXCHANGE_INFO_TEMPLATE_ID,
2229            SBE_SCHEMA_ID,
2230            SBE_SCHEMA_VERSION,
2231        );
2232
2233        let mut buf = Vec::new();
2234        buf.extend_from_slice(&header);
2235
2236        // Empty rate_limits group
2237        buf.extend_from_slice(&create_group_header(11, 0));
2238
2239        // Empty exchange_filters group
2240        buf.extend_from_slice(&create_group_header(0, 0));
2241
2242        // Symbols group: 2 symbols
2243        buf.extend_from_slice(&create_group_header(SYMBOL_BLOCK_LENGTH as u16, 2));
2244
2245        // Symbol 1: BTCUSDT
2246        buf.push(0); // status
2247        buf.push(8); // base_asset_precision
2248        buf.push(8); // quote_asset_precision
2249        buf.push(8); // base_commission_precision
2250        buf.push(8); // quote_commission_precision
2251        buf.extend_from_slice(&0b0000_0011u16.to_le_bytes()); // order_types
2252        buf.push(1); // iceberg_allowed
2253        buf.push(1); // oco_allowed
2254        buf.push(0); // oto_allowed
2255        buf.push(1); // quote_order_qty_market_allowed
2256        buf.push(1); // allow_trailing_stop
2257        buf.push(1); // cancel_replace_allowed
2258        buf.push(0); // amend_allowed
2259        buf.push(1); // is_spot_trading_allowed
2260        buf.push(0); // is_margin_trading_allowed
2261        buf.push(0); // default_self_trade_prevention_mode
2262        buf.push(0); // allowed_self_trade_prevention_modes
2263        buf.push(0); // peg_instructions_allowed
2264        buf.extend_from_slice(&create_group_header(0, 0)); // No filters
2265        buf.extend_from_slice(&create_group_header(0, 0)); // No permission sets
2266        write_var_string(&mut buf, "BTCUSDT");
2267        write_var_string(&mut buf, "BTC");
2268        write_var_string(&mut buf, "USDT");
2269
2270        // Symbol 2: ETHUSDT
2271        buf.push(0); // status
2272        buf.push(8); // base_asset_precision
2273        buf.push(8); // quote_asset_precision
2274        buf.push(8); // base_commission_precision
2275        buf.push(8); // quote_commission_precision
2276        buf.extend_from_slice(&0b0000_0011u16.to_le_bytes()); // order_types
2277        buf.push(1); // iceberg_allowed
2278        buf.push(1); // oco_allowed
2279        buf.push(0); // oto_allowed
2280        buf.push(1); // quote_order_qty_market_allowed
2281        buf.push(1); // allow_trailing_stop
2282        buf.push(1); // cancel_replace_allowed
2283        buf.push(0); // amend_allowed
2284        buf.push(1); // is_spot_trading_allowed
2285        buf.push(1); // is_margin_trading_allowed
2286        buf.push(0); // default_self_trade_prevention_mode
2287        buf.push(0); // allowed_self_trade_prevention_modes
2288        buf.push(0); // peg_instructions_allowed
2289        buf.extend_from_slice(&create_group_header(0, 0)); // No filters
2290        buf.extend_from_slice(&create_group_header(0, 0)); // No permission sets
2291        write_var_string(&mut buf, "ETHUSDT");
2292        write_var_string(&mut buf, "ETH");
2293        write_var_string(&mut buf, "USDT");
2294
2295        let info = decode_exchange_info(&buf).unwrap();
2296
2297        assert_eq!(info.symbols.len(), 2);
2298        assert_eq!(info.symbols[0].symbol, "BTCUSDT");
2299        assert_eq!(info.symbols[0].base_asset, "BTC");
2300        assert!(!info.symbols[0].is_margin_trading_allowed);
2301
2302        assert_eq!(info.symbols[1].symbol, "ETHUSDT");
2303        assert_eq!(info.symbols[1].base_asset, "ETH");
2304        assert!(info.symbols[1].is_margin_trading_allowed);
2305    }
2306
2307    #[rstest]
2308    fn test_decode_klines_valid() {
2309        let header = create_header(2, KLINES_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
2310
2311        let mut buf = Vec::new();
2312        buf.extend_from_slice(&header);
2313        buf.push((-2i8) as u8); // price_exponent
2314        buf.push((-4i8) as u8); // qty_exponent
2315        buf.extend_from_slice(&create_group_header(KLINES_BLOCK_LENGTH, 1));
2316        buf.extend_from_slice(&1_700_000_000_000_000i64.to_le_bytes()); // open_time (micros)
2317        buf.extend_from_slice(&12_000i64.to_le_bytes()); // open_price
2318        buf.extend_from_slice(&12_500i64.to_le_bytes()); // high_price
2319        buf.extend_from_slice(&11_900i64.to_le_bytes()); // low_price
2320        buf.extend_from_slice(&12_345i64.to_le_bytes()); // close_price
2321        buf.extend_from_slice(&1_234_500i128.to_le_bytes()); // volume
2322        buf.extend_from_slice(&1_700_000_059_999_000i64.to_le_bytes()); // close_time (micros)
2323        buf.extend_from_slice(&2_345_600i128.to_le_bytes()); // quote_volume
2324        buf.extend_from_slice(&100i64.to_le_bytes()); // num_trades
2325        buf.extend_from_slice(&600_000i128.to_le_bytes()); // taker_buy_base_volume
2326        buf.extend_from_slice(&1_200_000i128.to_le_bytes()); // taker_buy_quote_volume
2327
2328        let klines = decode_klines(&buf).unwrap();
2329
2330        assert_eq!(klines.price_exponent, -2);
2331        assert_eq!(klines.qty_exponent, -4);
2332        assert_eq!(klines.klines.len(), 1);
2333        assert_eq!(klines.klines[0].open_time, 1_700_000_000_000_000);
2334        assert_eq!(klines.klines[0].close_price, 12_345);
2335        assert_eq!(i128::from_le_bytes(klines.klines[0].volume), 1_234_500);
2336        assert_eq!(klines.klines[0].num_trades, 100);
2337    }
2338
2339    #[rstest]
2340    fn test_decode_new_order_full_valid() {
2341        let header = create_header(
2342            NEW_ORDER_FULL_BLOCK_LENGTH as u16,
2343            NEW_ORDER_FULL_TEMPLATE_ID,
2344            SBE_SCHEMA_ID,
2345            SBE_SCHEMA_VERSION,
2346        );
2347
2348        let mut buf = Vec::new();
2349        buf.extend_from_slice(&header);
2350
2351        buf.push((-2i8) as u8); // price_exponent
2352        buf.push((-4i8) as u8); // qty_exponent
2353        buf.extend_from_slice(&12345i64.to_le_bytes()); // order_id
2354        buf.extend_from_slice(&99i64.to_le_bytes()); // order_list_id
2355        buf.extend_from_slice(&1_700_000_000_000_000i64.to_le_bytes()); // transact_time
2356        buf.extend_from_slice(&12_345i64.to_le_bytes()); // price_mantissa
2357        buf.extend_from_slice(&25_000i64.to_le_bytes()); // orig_qty
2358        buf.extend_from_slice(&10_000i64.to_le_bytes()); // executed_qty
2359        buf.extend_from_slice(&123_450_000i64.to_le_bytes()); // cumulative_quote_qty
2360        buf.push(2); // status (PARTIALLY_FILLED)
2361        buf.push(1); // time_in_force (GTC)
2362        buf.push(1); // order_type (LIMIT)
2363        buf.push(1); // side (BUY)
2364        buf.extend_from_slice(&12_000i64.to_le_bytes()); // stop_price
2365        buf.extend_from_slice(&[0u8; 16]); // trailing_delta + trailing_time
2366        buf.extend_from_slice(&1_700_000_000_000_500i64.to_le_bytes()); // working_time
2367        buf.extend_from_slice(&[0u8; 23]); // iceberg to used_sor
2368        buf.push(0); // self_trade_prevention_mode
2369        buf.extend_from_slice(&[0u8; 16]); // trade_group_id + prevented_quantity
2370        buf.push((-8i8) as u8); // commission_exponent
2371        buf.extend_from_slice(&[0u8; 18]); // rest of block
2372
2373        buf.extend_from_slice(&create_group_header(FILLS_BLOCK_LENGTH, 1));
2374        buf.push((-8i8) as u8); // commission_exponent
2375        buf.push(0); // match_type
2376        buf.extend_from_slice(&12_345i64.to_le_bytes()); // fill price
2377        buf.extend_from_slice(&10_000i64.to_le_bytes()); // fill qty
2378        buf.extend_from_slice(&10_000i64.to_le_bytes()); // commission
2379        buf.extend_from_slice(&555i64.to_le_bytes()); // trade_id
2380        buf.extend_from_slice(&0i64.to_le_bytes()); // alloc_id
2381        write_var_string(&mut buf, "USDT");
2382
2383        buf.extend_from_slice(&create_group_header(0, 0)); // prevented matches
2384        write_var_string(&mut buf, "ETHUSDT");
2385        write_var_string(&mut buf, "client-123");
2386
2387        let response = decode_new_order_full(&buf).unwrap();
2388
2389        assert_eq!(response.order_id, 12345);
2390        assert_eq!(response.order_list_id, Some(99));
2391        assert_eq!(response.transact_time, 1_700_000_000_000_000);
2392        assert_eq!(response.price_mantissa, 12_345);
2393        assert_eq!(response.orig_qty_mantissa, 25_000);
2394        assert_eq!(response.executed_qty_mantissa, 10_000);
2395        assert_eq!(response.stop_price_mantissa, Some(12_000));
2396        assert_eq!(response.working_time, Some(1_700_000_000_000_500));
2397        assert_eq!(response.symbol, "ETHUSDT");
2398        assert_eq!(response.client_order_id, "client-123");
2399        assert_eq!(response.fills.len(), 1);
2400        assert_eq!(response.fills[0].price_mantissa, 12_345);
2401        assert_eq!(response.fills[0].qty_mantissa, 10_000);
2402        assert_eq!(response.fills[0].trade_id, Some(555));
2403        assert_eq!(response.fills[0].commission_asset, "USDT");
2404    }
2405
2406    /// Builds a schema-3:4 `newOrderFullResponse` SBE buffer with the supplied
2407    /// trailing `expiryReason` byte. Used by the v3 block-length tests.
2408    fn build_new_order_full_v3_buffer(expiry_reason_byte: u8) -> Vec<u8> {
2409        const V3_BLOCK_LENGTH: u16 = 154;
2410        let header = create_header(
2411            V3_BLOCK_LENGTH,
2412            NEW_ORDER_FULL_TEMPLATE_ID,
2413            SBE_SCHEMA_ID,
2414            SBE_SCHEMA_VERSION,
2415        );
2416
2417        let mut buf = Vec::new();
2418        buf.extend_from_slice(&header);
2419
2420        buf.push((-2i8) as u8); // price_exponent
2421        buf.push((-4i8) as u8); // qty_exponent
2422        buf.extend_from_slice(&12345i64.to_le_bytes()); // order_id
2423        buf.extend_from_slice(&99i64.to_le_bytes()); // order_list_id
2424        buf.extend_from_slice(&1_700_000_000_000_000i64.to_le_bytes()); // transact_time
2425        buf.extend_from_slice(&12_345i64.to_le_bytes()); // price_mantissa
2426        buf.extend_from_slice(&25_000i64.to_le_bytes()); // orig_qty
2427        buf.extend_from_slice(&10_000i64.to_le_bytes()); // executed_qty
2428        buf.extend_from_slice(&123_450_000i64.to_le_bytes()); // cumulative_quote_qty
2429        buf.push(2); // status (PARTIALLY_FILLED)
2430        buf.push(1); // time_in_force (GTC)
2431        buf.push(1); // order_type (LIMIT)
2432        buf.push(1); // side (BUY)
2433        buf.extend_from_slice(&12_000i64.to_le_bytes()); // stop_price
2434        buf.extend_from_slice(&[0u8; 16]); // trailing_delta + trailing_time
2435        buf.extend_from_slice(&1_700_000_000_000_500i64.to_le_bytes()); // working_time
2436        buf.extend_from_slice(&[0u8; 23]); // iceberg to used_sor
2437        buf.push(0); // self_trade_prevention_mode
2438        buf.extend_from_slice(&[0u8; 16]); // trade_group_id + prevented_quantity
2439        buf.push((-8i8) as u8); // commission_exponent
2440        buf.extend_from_slice(&[0u8; 18]); // peg fields
2441        buf.push(expiry_reason_byte); // expiryReason
2442
2443        buf.extend_from_slice(&create_group_header(FILLS_BLOCK_LENGTH, 1));
2444        buf.push((-8i8) as u8); // commission_exponent
2445        buf.push(0); // match_type
2446        buf.extend_from_slice(&12_345i64.to_le_bytes()); // fill price
2447        buf.extend_from_slice(&10_000i64.to_le_bytes()); // fill qty
2448        buf.extend_from_slice(&10_000i64.to_le_bytes()); // commission
2449        buf.extend_from_slice(&555i64.to_le_bytes()); // trade_id
2450        buf.extend_from_slice(&0i64.to_le_bytes()); // alloc_id
2451        write_var_string(&mut buf, "USDT");
2452
2453        buf.extend_from_slice(&create_group_header(0, 0)); // prevented matches
2454        write_var_string(&mut buf, "ETHUSDT");
2455        write_var_string(&mut buf, "client-456");
2456        buf
2457    }
2458
2459    #[rstest]
2460    fn test_decode_new_order_full_v3_block_length() {
2461        // Schema v3 adds expiryReason (1 byte) at the end of the fixed block,
2462        // increasing block_length from 153 to 154. A 0xFF byte marks null.
2463        let buf = build_new_order_full_v3_buffer(0xFF);
2464
2465        let response = decode_new_order_full(&buf).unwrap();
2466
2467        assert_eq!(response.order_id, 12345);
2468        assert_eq!(response.symbol, "ETHUSDT");
2469        assert_eq!(response.client_order_id, "client-456");
2470        assert_eq!(response.fills.len(), 1);
2471        assert_eq!(response.fills[0].price_mantissa, 12_345);
2472        assert!(response.expiry_reason.is_none());
2473    }
2474
2475    #[rstest]
2476    fn test_decode_new_order_full_v3_captures_expiry_reason_value() {
2477        // 0x05 = UnfilledIocQuantityExpired per the SBE ExpiryReason enum;
2478        // decode_new_order_full must surface it on the response.
2479        let buf = build_new_order_full_v3_buffer(0x05);
2480
2481        let response = decode_new_order_full(&buf).unwrap();
2482
2483        assert_eq!(response.expiry_reason, Some(0x05));
2484        // Symbol parses correctly only if the cursor lands at the right
2485        // offset after the expiry_reason read.
2486        assert_eq!(response.symbol, "ETHUSDT");
2487        assert_eq!(response.client_order_id, "client-456");
2488    }
2489
2490    #[rstest]
2491    fn test_decode_cancel_open_orders_valid() {
2492        let header = create_header(
2493            0,
2494            CANCEL_OPEN_ORDERS_TEMPLATE_ID,
2495            SBE_SCHEMA_ID,
2496            SBE_SCHEMA_VERSION,
2497        );
2498        let response_one = create_cancel_order_response_buffer(111, "ETHUSDT", "orig-1", "new-1");
2499        let response_two = create_cancel_order_response_buffer(222, "BTCUSDT", "orig-2", "new-2");
2500
2501        let mut buf = Vec::new();
2502        buf.extend_from_slice(&header);
2503        buf.extend_from_slice(&create_group_header(0, 2));
2504        buf.extend_from_slice(&(response_one.len() as u16).to_le_bytes());
2505        buf.extend_from_slice(&response_one);
2506        buf.extend_from_slice(&(response_two.len() as u16).to_le_bytes());
2507        buf.extend_from_slice(&response_two);
2508
2509        let responses = decode_cancel_open_orders(&buf).unwrap();
2510
2511        assert_eq!(responses.len(), 2);
2512        assert_eq!(responses[0].order_id, 111);
2513        assert_eq!(responses[0].symbol, "ETHUSDT");
2514        assert_eq!(responses[0].orig_client_order_id, "orig-1");
2515        assert_eq!(responses[0].client_order_id, "new-1");
2516        assert_eq!(responses[1].order_id, 222);
2517        assert_eq!(responses[1].symbol, "BTCUSDT");
2518        assert_eq!(responses[1].orig_client_order_id, "orig-2");
2519        assert_eq!(responses[1].client_order_id, "new-2");
2520    }
2521
2522    fn create_cancel_order_response_buffer(
2523        order_id: i64,
2524        symbol: &str,
2525        orig_client_order_id: &str,
2526        client_order_id: &str,
2527    ) -> Vec<u8> {
2528        let header = create_header(
2529            CANCEL_ORDER_BLOCK_LENGTH as u16,
2530            CANCEL_ORDER_TEMPLATE_ID,
2531            SBE_SCHEMA_ID,
2532            SBE_SCHEMA_VERSION,
2533        );
2534
2535        let mut buf = Vec::new();
2536        buf.extend_from_slice(&header);
2537        buf.push((-8i8) as u8); // price_exponent
2538        buf.push((-8i8) as u8); // qty_exponent
2539        buf.extend_from_slice(&order_id.to_le_bytes());
2540        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id
2541        buf.extend_from_slice(&1_700_000_000_000_000i64.to_le_bytes()); // transact_time
2542        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price
2543        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
2544        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // executed_qty
2545        buf.extend_from_slice(&1_000_000_000i64.to_le_bytes()); // cumulative_quote_qty
2546        buf.push(4); // status (CANCELED)
2547        buf.push(1); // time_in_force
2548        buf.push(1); // order_type
2549        buf.push(1); // side
2550        buf.push(0); // self_trade_prevention_mode
2551
2552        while buf.len() < 8 + CANCEL_ORDER_BLOCK_LENGTH {
2553            buf.push(0);
2554        }
2555
2556        write_var_string(&mut buf, symbol);
2557        write_var_string(&mut buf, orig_client_order_id);
2558        write_var_string(&mut buf, client_order_id);
2559
2560        buf
2561    }
2562}