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