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::common::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    #[allow(dead_code)]
58    block_length: u16,
59    template_id: u16,
60    schema_id: u16,
61    version: u16,
62}
63
64impl MessageHeader {
65    /// Decode message header using cursor.
66    fn decode_cursor(cursor: &mut SbeCursor<'_>) -> Result<Self, SbeDecodeError> {
67        cursor.require(HEADER_LENGTH)?;
68        Ok(Self {
69            block_length: cursor.read_u16_le()?,
70            template_id: cursor.read_u16_le()?,
71            schema_id: cursor.read_u16_le()?,
72            version: cursor.read_u16_le()?,
73        })
74    }
75
76    /// Validate schema ID and version.
77    fn validate(&self) -> Result<(), SbeDecodeError> {
78        if self.schema_id != SBE_SCHEMA_ID {
79            return Err(SbeDecodeError::SchemaMismatch {
80                expected: SBE_SCHEMA_ID,
81                actual: self.schema_id,
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/// Block length for new order full response.
302const NEW_ORDER_FULL_BLOCK_LENGTH: usize = 153;
303
304/// Block length for cancel order response.
305const CANCEL_ORDER_BLOCK_LENGTH: usize = 137;
306
307/// Block length for order response (query).
308const ORDER_BLOCK_LENGTH: usize = 153;
309
310/// Decode a new order full response.
311///
312/// # Errors
313///
314/// Returns error if buffer is too short, schema mismatch, or decode error.
315#[allow(dead_code)]
316pub fn decode_new_order_full(buf: &[u8]) -> Result<BinanceNewOrderResponse, SbeDecodeError> {
317    let mut cursor = SbeCursor::new(buf);
318    let header = MessageHeader::decode_cursor(&mut cursor)?;
319    header.validate()?;
320
321    if header.template_id != NEW_ORDER_FULL_TEMPLATE_ID {
322        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
323    }
324
325    cursor.require(NEW_ORDER_FULL_BLOCK_LENGTH)?;
326
327    let price_exponent = cursor.read_i8()?;
328    let qty_exponent = cursor.read_i8()?;
329    let order_id = cursor.read_i64_le()?;
330    let order_list_id = cursor.read_optional_i64_le()?;
331    let transact_time = cursor.read_i64_le()?;
332    let price_mantissa = cursor.read_i64_le()?;
333    let orig_qty_mantissa = cursor.read_i64_le()?;
334    let executed_qty_mantissa = cursor.read_i64_le()?;
335    let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
336    let status = cursor.read_u8()?.into();
337    let time_in_force = cursor.read_u8()?.into();
338    let order_type = cursor.read_u8()?.into();
339    let side = cursor.read_u8()?.into();
340    let stop_price_mantissa = cursor.read_optional_i64_le()?;
341
342    cursor.advance(16)?; // Skip trailing_delta (8) + trailing_time (8)
343    let working_time = cursor.read_optional_i64_le()?;
344
345    cursor.advance(23)?; // Skip iceberg to used_sor
346    let self_trade_prevention_mode = cursor.read_u8()?.into();
347
348    cursor.advance(16)?; // Skip trade_group_id + prevented_quantity
349    let _commission_exponent = cursor.read_i8()?;
350
351    cursor.advance(18)?; // Skip to end of fixed block
352
353    let fills = decode_fills_cursor(&mut cursor)?;
354
355    // Skip prevented matches group
356    let (block_len, count) = cursor.read_group_header()?;
357    cursor.advance(block_len as usize * count as usize)?;
358
359    let symbol = cursor.read_var_string8()?;
360    let client_order_id = cursor.read_var_string8()?;
361
362    Ok(BinanceNewOrderResponse {
363        price_exponent,
364        qty_exponent,
365        order_id,
366        order_list_id,
367        transact_time,
368        price_mantissa,
369        orig_qty_mantissa,
370        executed_qty_mantissa,
371        cummulative_quote_qty_mantissa,
372        status,
373        time_in_force,
374        order_type,
375        side,
376        stop_price_mantissa,
377        working_time,
378        self_trade_prevention_mode,
379        client_order_id,
380        symbol,
381        fills,
382    })
383}
384
385/// Decode a cancel order response.
386///
387/// # Errors
388///
389/// Returns error if buffer is too short, schema mismatch, or decode error.
390#[allow(dead_code)]
391pub fn decode_cancel_order(buf: &[u8]) -> Result<BinanceCancelOrderResponse, SbeDecodeError> {
392    let mut cursor = SbeCursor::new(buf);
393    let header = MessageHeader::decode_cursor(&mut cursor)?;
394    header.validate()?;
395
396    if header.template_id != CANCEL_ORDER_TEMPLATE_ID {
397        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
398    }
399
400    cursor.require(CANCEL_ORDER_BLOCK_LENGTH)?;
401
402    let price_exponent = cursor.read_i8()?;
403    let qty_exponent = cursor.read_i8()?;
404    let order_id = cursor.read_i64_le()?;
405    let order_list_id = cursor.read_optional_i64_le()?;
406    let transact_time = cursor.read_i64_le()?;
407    let price_mantissa = cursor.read_i64_le()?;
408    let orig_qty_mantissa = cursor.read_i64_le()?;
409    let executed_qty_mantissa = cursor.read_i64_le()?;
410    let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
411    let status = cursor.read_u8()?.into();
412    let time_in_force = cursor.read_u8()?.into();
413    let order_type = cursor.read_u8()?.into();
414    let side = cursor.read_u8()?.into();
415    let self_trade_prevention_mode = cursor.read_u8()?.into();
416
417    cursor.advance(CANCEL_ORDER_BLOCK_LENGTH - 63)?; // Skip to end of fixed block
418
419    let symbol = cursor.read_var_string8()?;
420    let orig_client_order_id = cursor.read_var_string8()?;
421    let client_order_id = cursor.read_var_string8()?;
422
423    Ok(BinanceCancelOrderResponse {
424        price_exponent,
425        qty_exponent,
426        order_id,
427        order_list_id,
428        transact_time,
429        price_mantissa,
430        orig_qty_mantissa,
431        executed_qty_mantissa,
432        cummulative_quote_qty_mantissa,
433        status,
434        time_in_force,
435        order_type,
436        side,
437        self_trade_prevention_mode,
438        client_order_id,
439        orig_client_order_id,
440        symbol,
441    })
442}
443
444/// Decode an order query response.
445///
446/// # Errors
447///
448/// Returns error if buffer is too short, schema mismatch, or decode error.
449#[allow(dead_code)]
450pub fn decode_order(buf: &[u8]) -> Result<BinanceOrderResponse, SbeDecodeError> {
451    let mut cursor = SbeCursor::new(buf);
452    let header = MessageHeader::decode_cursor(&mut cursor)?;
453    header.validate()?;
454
455    if header.template_id != ORDER_TEMPLATE_ID {
456        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
457    }
458
459    cursor.require(ORDER_BLOCK_LENGTH)?;
460
461    let price_exponent = cursor.read_i8()?;
462    let qty_exponent = cursor.read_i8()?;
463    let order_id = cursor.read_i64_le()?;
464    let order_list_id = cursor.read_optional_i64_le()?;
465    let price_mantissa = cursor.read_i64_le()?;
466    let orig_qty_mantissa = cursor.read_i64_le()?;
467    let executed_qty_mantissa = cursor.read_i64_le()?;
468    let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
469    let status = cursor.read_u8()?.into();
470    let time_in_force = cursor.read_u8()?.into();
471    let order_type = cursor.read_u8()?.into();
472    let side = cursor.read_u8()?.into();
473    let stop_price_mantissa = cursor.read_optional_i64_le()?;
474    let iceberg_qty_mantissa = cursor.read_optional_i64_le()?;
475    let time = cursor.read_i64_le()?;
476    let update_time = cursor.read_i64_le()?;
477    let is_working = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
478    let working_time = cursor.read_optional_i64_le()?;
479    let orig_quote_order_qty_mantissa = cursor.read_i64_le()?;
480    let self_trade_prevention_mode = cursor.read_u8()?.into();
481
482    cursor.advance(ORDER_BLOCK_LENGTH - 104)?; // Skip to end of fixed block
483
484    let symbol = cursor.read_var_string8()?;
485    let client_order_id = cursor.read_var_string8()?;
486
487    Ok(BinanceOrderResponse {
488        price_exponent,
489        qty_exponent,
490        order_id,
491        order_list_id,
492        price_mantissa,
493        orig_qty_mantissa,
494        executed_qty_mantissa,
495        cummulative_quote_qty_mantissa,
496        status,
497        time_in_force,
498        order_type,
499        side,
500        stop_price_mantissa,
501        iceberg_qty_mantissa,
502        time,
503        update_time,
504        is_working,
505        working_time,
506        orig_quote_order_qty_mantissa,
507        self_trade_prevention_mode,
508        client_order_id,
509        symbol,
510    })
511}
512
513/// Block length for orders group item.
514const ORDERS_GROUP_BLOCK_LENGTH: usize = 162;
515
516/// Decode multiple orders response.
517///
518/// # Errors
519///
520/// Returns error if buffer is too short, schema mismatch, or decode error.
521#[allow(dead_code)]
522pub fn decode_orders(buf: &[u8]) -> Result<Vec<BinanceOrderResponse>, SbeDecodeError> {
523    let mut cursor = SbeCursor::new(buf);
524    let header = MessageHeader::decode_cursor(&mut cursor)?;
525    header.validate()?;
526
527    if header.template_id != ORDERS_TEMPLATE_ID {
528        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
529    }
530
531    let (block_length, count) = cursor.read_group_header()?;
532
533    if count == 0 {
534        return Ok(Vec::new());
535    }
536
537    if block_length as usize != ORDERS_GROUP_BLOCK_LENGTH {
538        return Err(SbeDecodeError::InvalidBlockLength {
539            expected: ORDERS_GROUP_BLOCK_LENGTH as u16,
540            actual: block_length,
541        });
542    }
543
544    let mut orders = Vec::with_capacity(count as usize);
545
546    for _ in 0..count {
547        cursor.require(ORDERS_GROUP_BLOCK_LENGTH)?;
548
549        let price_exponent = cursor.read_i8()?;
550        let qty_exponent = cursor.read_i8()?;
551        let order_id = cursor.read_i64_le()?;
552        let order_list_id = cursor.read_optional_i64_le()?;
553        let price_mantissa = cursor.read_i64_le()?;
554        let orig_qty_mantissa = cursor.read_i64_le()?;
555        let executed_qty_mantissa = cursor.read_i64_le()?;
556        let cummulative_quote_qty_mantissa = cursor.read_i64_le()?;
557        let status = cursor.read_u8()?.into();
558        let time_in_force = cursor.read_u8()?.into();
559        let order_type = cursor.read_u8()?.into();
560        let side = cursor.read_u8()?.into();
561        let stop_price_mantissa = cursor.read_optional_i64_le()?;
562
563        cursor.advance(16)?; // Skip trailing_delta + trailing_time
564        let iceberg_qty_mantissa = cursor.read_optional_i64_le()?;
565        let time = cursor.read_i64_le()?;
566        let update_time = cursor.read_i64_le()?;
567        let is_working = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
568        let working_time = cursor.read_optional_i64_le()?;
569        let orig_quote_order_qty_mantissa = cursor.read_i64_le()?;
570
571        cursor.advance(14)?; // Skip strategy_id to working_floor
572        let self_trade_prevention_mode = cursor.read_u8()?.into();
573
574        cursor.advance(28)?; // Skip to end of fixed block
575
576        let symbol = cursor.read_var_string8()?;
577        let client_order_id = cursor.read_var_string8()?;
578
579        orders.push(BinanceOrderResponse {
580            price_exponent,
581            qty_exponent,
582            order_id,
583            order_list_id,
584            price_mantissa,
585            orig_qty_mantissa,
586            executed_qty_mantissa,
587            cummulative_quote_qty_mantissa,
588            status,
589            time_in_force,
590            order_type,
591            side,
592            stop_price_mantissa,
593            iceberg_qty_mantissa,
594            time,
595            update_time,
596            is_working,
597            working_time,
598            orig_quote_order_qty_mantissa,
599            self_trade_prevention_mode,
600            client_order_id,
601            symbol,
602        });
603    }
604
605    Ok(orders)
606}
607
608/// Decode cancel open orders response.
609///
610/// Each item in the response group contains an embedded cancel_order_response SBE message.
611///
612/// # Errors
613///
614/// Returns error if buffer is too short, schema mismatch, or decode error.
615#[allow(dead_code)]
616pub fn decode_cancel_open_orders(
617    buf: &[u8],
618) -> Result<Vec<BinanceCancelOrderResponse>, SbeDecodeError> {
619    let mut cursor = SbeCursor::new(buf);
620    let header = MessageHeader::decode_cursor(&mut cursor)?;
621    header.validate()?;
622
623    if header.template_id != CANCEL_OPEN_ORDERS_TEMPLATE_ID {
624        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
625    }
626
627    let (_block_length, count) = cursor.read_group_header()?;
628
629    if count == 0 {
630        return Ok(Vec::new());
631    }
632
633    let mut responses = Vec::with_capacity(count as usize);
634
635    // Each group item has block_length=0, followed by u16 length + embedded SBE message
636    for _ in 0..count {
637        let response_len = cursor.read_u16_le()? as usize;
638        let embedded_bytes = cursor.read_bytes(response_len)?;
639        let cancel_response = decode_cancel_order(embedded_bytes)?;
640        responses.push(cancel_response);
641    }
642
643    Ok(responses)
644}
645
646/// Account response block length (from SBE codec).
647const ACCOUNT_BLOCK_LENGTH: usize = 64;
648
649/// Balance group item block length (from SBE codec).
650const BALANCE_BLOCK_LENGTH: u16 = 17;
651
652/// Decode account information response.
653///
654/// # Errors
655///
656/// Returns error if buffer is too short, schema mismatch, or decode error.
657#[allow(dead_code)]
658pub fn decode_account(buf: &[u8]) -> Result<BinanceAccountInfo, SbeDecodeError> {
659    let mut cursor = SbeCursor::new(buf);
660    let header = MessageHeader::decode_cursor(&mut cursor)?;
661    header.validate()?;
662
663    if header.template_id != ACCOUNT_TEMPLATE_ID {
664        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
665    }
666
667    cursor.require(ACCOUNT_BLOCK_LENGTH)?;
668
669    let commission_exponent = cursor.read_i8()?;
670    let maker_commission_mantissa = cursor.read_i64_le()?;
671    let taker_commission_mantissa = cursor.read_i64_le()?;
672    let buyer_commission_mantissa = cursor.read_i64_le()?;
673    let seller_commission_mantissa = cursor.read_i64_le()?;
674    let can_trade = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
675    let can_withdraw = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
676    let can_deposit = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
677    cursor.advance(1)?; // Skip brokered
678    let require_self_trade_prevention = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
679    let prevent_sor = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
680    let update_time = cursor.read_i64_le()?;
681    let account_type_enum = AccountType::from(cursor.read_u8()?);
682    cursor.advance(16)?; // Skip tradeGroupId + uid
683
684    let account_type = account_type_enum.to_string();
685
686    let (block_length, balance_count) = cursor.read_group_header()?;
687
688    if block_length != BALANCE_BLOCK_LENGTH {
689        return Err(SbeDecodeError::InvalidBlockLength {
690            expected: BALANCE_BLOCK_LENGTH,
691            actual: block_length,
692        });
693    }
694
695    let mut balances = Vec::with_capacity(balance_count as usize);
696
697    for _ in 0..balance_count {
698        cursor.require(block_length as usize)?;
699
700        let exponent = cursor.read_i8()?;
701        let free_mantissa = cursor.read_i64_le()?;
702        let locked_mantissa = cursor.read_i64_le()?;
703
704        let asset = cursor.read_var_string8()?;
705
706        balances.push(BinanceBalance {
707            asset,
708            free_mantissa,
709            locked_mantissa,
710            exponent,
711        });
712    }
713
714    Ok(BinanceAccountInfo {
715        commission_exponent,
716        maker_commission_mantissa,
717        taker_commission_mantissa,
718        buyer_commission_mantissa,
719        seller_commission_mantissa,
720        can_trade,
721        can_withdraw,
722        can_deposit,
723        require_self_trade_prevention,
724        prevent_sor,
725        update_time,
726        account_type,
727        balances,
728    })
729}
730
731/// Account trade group item block length (from SBE codec).
732const ACCOUNT_TRADE_BLOCK_LENGTH: u16 = 70;
733
734/// Decode account trades response.
735///
736/// # Errors
737///
738/// Returns error if buffer is too short, schema mismatch, or decode error.
739#[allow(dead_code)]
740pub fn decode_account_trades(buf: &[u8]) -> Result<Vec<BinanceAccountTrade>, SbeDecodeError> {
741    let mut cursor = SbeCursor::new(buf);
742    let header = MessageHeader::decode_cursor(&mut cursor)?;
743    header.validate()?;
744
745    if header.template_id != ACCOUNT_TRADES_TEMPLATE_ID {
746        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
747    }
748
749    let (block_length, trade_count) = cursor.read_group_header()?;
750
751    if block_length != ACCOUNT_TRADE_BLOCK_LENGTH {
752        return Err(SbeDecodeError::InvalidBlockLength {
753            expected: ACCOUNT_TRADE_BLOCK_LENGTH,
754            actual: block_length,
755        });
756    }
757
758    let mut trades = Vec::with_capacity(trade_count as usize);
759
760    for _ in 0..trade_count {
761        cursor.require(block_length as usize)?;
762
763        let price_exponent = cursor.read_i8()?;
764        let qty_exponent = cursor.read_i8()?;
765        let commission_exponent = cursor.read_i8()?;
766        let id = cursor.read_i64_le()?;
767        let order_id = cursor.read_i64_le()?;
768        let order_list_id = cursor.read_optional_i64_le()?;
769        let price_mantissa = cursor.read_i64_le()?;
770        let qty_mantissa = cursor.read_i64_le()?;
771        let quote_qty_mantissa = cursor.read_i64_le()?;
772        let commission_mantissa = cursor.read_i64_le()?;
773        let time = cursor.read_i64_le()?;
774        let is_buyer = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
775        let is_maker = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
776        let is_best_match = BoolEnum::from(cursor.read_u8()?) == BoolEnum::True;
777
778        let symbol = cursor.read_var_string8()?;
779        let commission_asset = cursor.read_var_string8()?;
780
781        trades.push(BinanceAccountTrade {
782            price_exponent,
783            qty_exponent,
784            commission_exponent,
785            id,
786            order_id,
787            order_list_id,
788            price_mantissa,
789            qty_mantissa,
790            quote_qty_mantissa,
791            commission_mantissa,
792            time,
793            is_buyer,
794            is_maker,
795            is_best_match,
796            symbol,
797            commission_asset,
798        });
799    }
800
801    Ok(trades)
802}
803
804/// Fills group item block length (from SBE codec).
805const FILLS_BLOCK_LENGTH: u16 = 42;
806
807/// Decode order fills using cursor.
808fn decode_fills_cursor(
809    cursor: &mut SbeCursor<'_>,
810) -> Result<Vec<BinanceOrderFill>, SbeDecodeError> {
811    let (block_length, count) = cursor.read_group_header()?;
812
813    if block_length != FILLS_BLOCK_LENGTH {
814        return Err(SbeDecodeError::InvalidBlockLength {
815            expected: FILLS_BLOCK_LENGTH,
816            actual: block_length,
817        });
818    }
819
820    let mut fills = Vec::with_capacity(count as usize);
821
822    for _ in 0..count {
823        cursor.require(block_length as usize)?;
824
825        let commission_exponent = cursor.read_i8()?;
826        cursor.advance(1)?; // Skip matchType
827        let price_mantissa = cursor.read_i64_le()?;
828        let qty_mantissa = cursor.read_i64_le()?;
829        let commission_mantissa = cursor.read_i64_le()?;
830        let trade_id = cursor.read_optional_i64_le()?;
831        cursor.advance(8)?; // Skip allocId
832
833        let commission_asset = cursor.read_var_string8()?;
834
835        fills.push(BinanceOrderFill {
836            price_mantissa,
837            qty_mantissa,
838            commission_mantissa,
839            commission_exponent,
840            commission_asset,
841            trade_id,
842        });
843    }
844
845    Ok(fills)
846}
847
848/// Symbols group block length (from SBE codec).
849const SYMBOL_BLOCK_LENGTH: usize = 19;
850
851/// Decode exchange info response.
852///
853/// ExchangeInfo response contains rate limits, exchange filters, symbols, and SOR info.
854/// We only decode the symbols array which contains instrument definitions.
855///
856/// # Errors
857///
858/// Returns error if buffer is too short, schema mismatch, or template ID mismatch.
859///
860/// # Panics
861///
862/// This function will panic if filter byte slices cannot be converted to fixed-size arrays,
863/// which should not occur if the SBE data is well-formed.
864pub fn decode_exchange_info(buf: &[u8]) -> Result<BinanceExchangeInfoSbe, SbeDecodeError> {
865    let mut cursor = SbeCursor::new(buf);
866    let header = MessageHeader::decode_cursor(&mut cursor)?;
867    header.validate()?;
868
869    if header.template_id != EXCHANGE_INFO_TEMPLATE_ID {
870        return Err(SbeDecodeError::UnknownTemplateId(header.template_id));
871    }
872
873    // Skip rate_limits group
874    let (rate_limits_block_len, rate_limits_count) = cursor.read_group_header()?;
875    cursor.advance(rate_limits_block_len as usize * rate_limits_count as usize)?;
876
877    // Skip exchange_filters group
878    let (_exchange_filters_block_len, exchange_filters_count) = cursor.read_group_header()?;
879    for _ in 0..exchange_filters_count {
880        // Each filter is a varString8
881        cursor.read_var_string8()?;
882    }
883
884    // Decode symbols group
885    let (symbols_block_len, symbols_count) = cursor.read_group_header()?;
886
887    if symbols_block_len != SYMBOL_BLOCK_LENGTH as u16 {
888        return Err(SbeDecodeError::InvalidBlockLength {
889            expected: SYMBOL_BLOCK_LENGTH as u16,
890            actual: symbols_block_len,
891        });
892    }
893
894    let mut symbols = Vec::with_capacity(symbols_count as usize);
895
896    for _ in 0..symbols_count {
897        cursor.require(SYMBOL_BLOCK_LENGTH)?;
898
899        // Fixed fields (19 bytes)
900        let status = cursor.read_u8()?;
901        let base_asset_precision = cursor.read_u8()?;
902        let quote_asset_precision = cursor.read_u8()?;
903        let _base_commission_precision = cursor.read_u8()?;
904        let _quote_commission_precision = cursor.read_u8()?;
905        let order_types = cursor.read_u16_le()?;
906        let iceberg_allowed = cursor.read_u8()? == BoolEnum::True as u8;
907        let oco_allowed = cursor.read_u8()? == BoolEnum::True as u8;
908        let oto_allowed = cursor.read_u8()? == BoolEnum::True as u8;
909        let quote_order_qty_market_allowed = cursor.read_u8()? == BoolEnum::True as u8;
910        let allow_trailing_stop = cursor.read_u8()? == BoolEnum::True as u8;
911        let cancel_replace_allowed = cursor.read_u8()? == BoolEnum::True as u8;
912        let amend_allowed = cursor.read_u8()? == BoolEnum::True as u8;
913        let is_spot_trading_allowed = cursor.read_u8()? == BoolEnum::True as u8;
914        let is_margin_trading_allowed = cursor.read_u8()? == BoolEnum::True as u8;
915        let _default_self_trade_prevention_mode = cursor.read_u8()?;
916        let _allowed_self_trade_prevention_modes = cursor.read_u8()?;
917        let _peg_instructions_allowed = cursor.read_u8()?;
918
919        let (_filters_block_len, filters_count) = cursor.read_group_header()?;
920        let mut filters = BinanceSymbolFiltersSbe::default();
921
922        for _ in 0..filters_count {
923            let filter_bytes = cursor.read_var_bytes8()?;
924
925            // Filters can have header (8 bytes) or be raw body only,
926            // detect format by checking if bytes [2..4] contain a valid template_id
927            let (template_id, offset) = if filter_bytes.len() >= HEADER_LENGTH + 2 {
928                let potential_template = u16::from_le_bytes([filter_bytes[2], filter_bytes[3]]);
929                if potential_template == PRICE_FILTER_TEMPLATE_ID
930                    || potential_template == LOT_SIZE_FILTER_TEMPLATE_ID
931                {
932                    (potential_template, HEADER_LENGTH)
933                } else {
934                    let raw_template = u16::from_le_bytes([filter_bytes[0], filter_bytes[1]]);
935                    (raw_template, 2)
936                }
937            } else if filter_bytes.len() >= 2 {
938                let raw_template = u16::from_le_bytes([filter_bytes[0], filter_bytes[1]]);
939                (raw_template, 2)
940            } else {
941                continue;
942            };
943
944            // Filter body layout: exponent(1) + min(8) + max(8) + size(8) = 25 bytes
945            match template_id {
946                PRICE_FILTER_TEMPLATE_ID => {
947                    if filter_bytes.len() >= offset + 25 {
948                        let price_exp = filter_bytes[offset] as i8;
949                        let min_price = i64::from_le_bytes(
950                            filter_bytes[offset + 1..offset + 9].try_into().unwrap(),
951                        );
952                        let max_price = i64::from_le_bytes(
953                            filter_bytes[offset + 9..offset + 17].try_into().unwrap(),
954                        );
955                        let tick_size = i64::from_le_bytes(
956                            filter_bytes[offset + 17..offset + 25].try_into().unwrap(),
957                        );
958                        filters.price_filter = Some(BinancePriceFilterSbe {
959                            price_exponent: price_exp,
960                            min_price,
961                            max_price,
962                            tick_size,
963                        });
964                    }
965                }
966                LOT_SIZE_FILTER_TEMPLATE_ID => {
967                    if filter_bytes.len() >= offset + 25 {
968                        let qty_exp = filter_bytes[offset] as i8;
969                        let min_qty = i64::from_le_bytes(
970                            filter_bytes[offset + 1..offset + 9].try_into().unwrap(),
971                        );
972                        let max_qty = i64::from_le_bytes(
973                            filter_bytes[offset + 9..offset + 17].try_into().unwrap(),
974                        );
975                        let step_size = i64::from_le_bytes(
976                            filter_bytes[offset + 17..offset + 25].try_into().unwrap(),
977                        );
978                        filters.lot_size_filter = Some(BinanceLotSizeFilterSbe {
979                            qty_exponent: qty_exp,
980                            min_qty,
981                            max_qty,
982                            step_size,
983                        });
984                    }
985                }
986                _ => {}
987            }
988        }
989
990        // Permission sets nested group
991        let (_perm_sets_block_len, perm_sets_count) = cursor.read_group_header()?;
992        let mut permissions = Vec::with_capacity(perm_sets_count as usize);
993        for _ in 0..perm_sets_count {
994            // Permissions nested group
995            let (_perms_block_len, perms_count) = cursor.read_group_header()?;
996            let mut perm_set = Vec::with_capacity(perms_count as usize);
997            for _ in 0..perms_count {
998                let perm = cursor.read_var_string8()?;
999                perm_set.push(perm);
1000            }
1001            permissions.push(perm_set);
1002        }
1003
1004        // Variable-length strings
1005        let symbol = cursor.read_var_string8()?;
1006        let base_asset = cursor.read_var_string8()?;
1007        let quote_asset = cursor.read_var_string8()?;
1008
1009        symbols.push(BinanceSymbolSbe {
1010            symbol,
1011            base_asset,
1012            quote_asset,
1013            base_asset_precision,
1014            quote_asset_precision,
1015            status,
1016            order_types,
1017            iceberg_allowed,
1018            oco_allowed,
1019            oto_allowed,
1020            quote_order_qty_market_allowed,
1021            allow_trailing_stop,
1022            cancel_replace_allowed,
1023            amend_allowed,
1024            is_spot_trading_allowed,
1025            is_margin_trading_allowed,
1026            filters,
1027            permissions,
1028        });
1029    }
1030
1031    // Skip SOR group (we don't need it)
1032
1033    Ok(BinanceExchangeInfoSbe { symbols })
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use rstest::rstest;
1039
1040    use super::*;
1041
1042    fn create_header(block_length: u16, template_id: u16, schema_id: u16, version: u16) -> [u8; 8] {
1043        let mut buf = [0u8; 8];
1044        buf[0..2].copy_from_slice(&block_length.to_le_bytes());
1045        buf[2..4].copy_from_slice(&template_id.to_le_bytes());
1046        buf[4..6].copy_from_slice(&schema_id.to_le_bytes());
1047        buf[6..8].copy_from_slice(&version.to_le_bytes());
1048        buf
1049    }
1050
1051    #[rstest]
1052    fn test_decode_ping_valid() {
1053        // Ping: block_length=0, template_id=101, schema_id=3, version=1
1054        let buf = create_header(0, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1055        assert!(decode_ping(&buf).is_ok());
1056    }
1057
1058    #[rstest]
1059    fn test_decode_ping_buffer_too_short() {
1060        let buf = [0u8; 4];
1061        let err = decode_ping(&buf).unwrap_err();
1062        assert!(matches!(err, SbeDecodeError::BufferTooShort { .. }));
1063    }
1064
1065    #[rstest]
1066    fn test_decode_ping_schema_mismatch() {
1067        let buf = create_header(0, PING_TEMPLATE_ID, 99, SBE_SCHEMA_VERSION);
1068        let err = decode_ping(&buf).unwrap_err();
1069        assert!(matches!(err, SbeDecodeError::SchemaMismatch { .. }));
1070    }
1071
1072    #[rstest]
1073    fn test_decode_ping_wrong_template() {
1074        let buf = create_header(0, 999, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1075        let err = decode_ping(&buf).unwrap_err();
1076        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(999)));
1077    }
1078
1079    #[rstest]
1080    fn test_decode_server_time_valid() {
1081        // ServerTime: block_length=8, template_id=102, schema_id=3, version=1
1082        let header = create_header(
1083            8,
1084            SERVER_TIME_TEMPLATE_ID,
1085            SBE_SCHEMA_ID,
1086            SBE_SCHEMA_VERSION,
1087        );
1088        let timestamp: i64 = 1734300000000; // Example timestamp
1089
1090        let mut buf = Vec::with_capacity(16);
1091        buf.extend_from_slice(&header);
1092        buf.extend_from_slice(&timestamp.to_le_bytes());
1093
1094        let result = decode_server_time(&buf).unwrap();
1095        assert_eq!(result, timestamp);
1096    }
1097
1098    #[rstest]
1099    fn test_decode_server_time_buffer_too_short() {
1100        // Header only, missing body
1101        let buf = create_header(
1102            8,
1103            SERVER_TIME_TEMPLATE_ID,
1104            SBE_SCHEMA_ID,
1105            SBE_SCHEMA_VERSION,
1106        );
1107        let err = decode_server_time(&buf).unwrap_err();
1108        assert!(matches!(err, SbeDecodeError::BufferTooShort { .. }));
1109    }
1110
1111    #[rstest]
1112    fn test_decode_server_time_wrong_template() {
1113        let header = create_header(8, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1114        let mut buf = Vec::with_capacity(16);
1115        buf.extend_from_slice(&header);
1116        buf.extend_from_slice(&0i64.to_le_bytes());
1117
1118        let err = decode_server_time(&buf).unwrap_err();
1119        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1120    }
1121
1122    #[rstest]
1123    fn test_decode_server_time_version_mismatch() {
1124        let header = create_header(8, SERVER_TIME_TEMPLATE_ID, SBE_SCHEMA_ID, 99);
1125        let mut buf = Vec::with_capacity(16);
1126        buf.extend_from_slice(&header);
1127        buf.extend_from_slice(&0i64.to_le_bytes());
1128
1129        let err = decode_server_time(&buf).unwrap_err();
1130        assert!(matches!(err, SbeDecodeError::VersionMismatch { .. }));
1131    }
1132
1133    fn create_group_header(block_length: u16, count: u32) -> [u8; 6] {
1134        let mut buf = [0u8; 6];
1135        buf[0..2].copy_from_slice(&block_length.to_le_bytes());
1136        buf[2..6].copy_from_slice(&count.to_le_bytes());
1137        buf
1138    }
1139
1140    #[rstest]
1141    fn test_decode_depth_valid() {
1142        // Depth: block_length=10, template_id=200
1143        let header = create_header(10, DEPTH_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1144
1145        let mut buf = Vec::new();
1146        buf.extend_from_slice(&header);
1147
1148        // Block: last_update_id (8) + price_exponent (1) + qty_exponent (1)
1149        let last_update_id: i64 = 123456789;
1150        let price_exponent: i8 = -8;
1151        let qty_exponent: i8 = -8;
1152        buf.extend_from_slice(&last_update_id.to_le_bytes());
1153        buf.push(price_exponent as u8);
1154        buf.push(qty_exponent as u8);
1155
1156        // Bids group: 2 levels
1157        buf.extend_from_slice(&create_group_header(16, 2));
1158        // Bid 1: price=100000000000, qty=50000000
1159        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes());
1160        buf.extend_from_slice(&50_000_000i64.to_le_bytes());
1161        // Bid 2: price=99900000000, qty=30000000
1162        buf.extend_from_slice(&99_900_000_000i64.to_le_bytes());
1163        buf.extend_from_slice(&30_000_000i64.to_le_bytes());
1164
1165        // Asks group: 1 level
1166        buf.extend_from_slice(&create_group_header(16, 1));
1167        // Ask 1: price=100100000000, qty=25000000
1168        buf.extend_from_slice(&100_100_000_000i64.to_le_bytes());
1169        buf.extend_from_slice(&25_000_000i64.to_le_bytes());
1170
1171        let depth = decode_depth(&buf).unwrap();
1172
1173        assert_eq!(depth.last_update_id, 123456789);
1174        assert_eq!(depth.price_exponent, -8);
1175        assert_eq!(depth.qty_exponent, -8);
1176        assert_eq!(depth.bids.len(), 2);
1177        assert_eq!(depth.asks.len(), 1);
1178        assert_eq!(depth.bids[0].price_mantissa, 100_000_000_000);
1179        assert_eq!(depth.bids[0].qty_mantissa, 50_000_000);
1180        assert_eq!(depth.asks[0].price_mantissa, 100_100_000_000);
1181    }
1182
1183    #[rstest]
1184    fn test_decode_depth_empty_book() {
1185        let header = create_header(10, DEPTH_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1186
1187        let mut buf = Vec::new();
1188        buf.extend_from_slice(&header);
1189        buf.extend_from_slice(&0i64.to_le_bytes()); // last_update_id
1190        buf.push(0); // price_exponent
1191        buf.push(0); // qty_exponent
1192
1193        // Empty bids
1194        buf.extend_from_slice(&create_group_header(16, 0));
1195        // Empty asks
1196        buf.extend_from_slice(&create_group_header(16, 0));
1197
1198        let depth = decode_depth(&buf).unwrap();
1199
1200        assert!(depth.bids.is_empty());
1201        assert!(depth.asks.is_empty());
1202    }
1203
1204    #[rstest]
1205    fn test_decode_trades_valid() {
1206        // Trades: block_length=2, template_id=201
1207        let header = create_header(2, TRADES_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1208
1209        let mut buf = Vec::new();
1210        buf.extend_from_slice(&header);
1211
1212        // Block: price_exponent (1) + qty_exponent (1)
1213        let price_exponent: i8 = -8;
1214        let qty_exponent: i8 = -8;
1215        buf.push(price_exponent as u8);
1216        buf.push(qty_exponent as u8);
1217
1218        // Trades group: 1 trade (42 bytes each)
1219        buf.extend_from_slice(&create_group_header(42, 1));
1220
1221        // Trade: id(8) + price(8) + qty(8) + quoteQty(8) + time(8) + isBuyerMaker(1) + isBestMatch(1)
1222        let trade_id: i64 = 999;
1223        let price: i64 = 100_000_000_000;
1224        let qty: i64 = 10_000_000;
1225        let quote_qty: i64 = 1_000_000_000_000;
1226        let time: i64 = 1734300000000;
1227        let is_buyer_maker: u8 = 1; // true
1228        let is_best_match: u8 = 1; // true
1229
1230        buf.extend_from_slice(&trade_id.to_le_bytes());
1231        buf.extend_from_slice(&price.to_le_bytes());
1232        buf.extend_from_slice(&qty.to_le_bytes());
1233        buf.extend_from_slice(&quote_qty.to_le_bytes());
1234        buf.extend_from_slice(&time.to_le_bytes());
1235        buf.push(is_buyer_maker);
1236        buf.push(is_best_match);
1237
1238        let trades = decode_trades(&buf).unwrap();
1239
1240        assert_eq!(trades.price_exponent, -8);
1241        assert_eq!(trades.qty_exponent, -8);
1242        assert_eq!(trades.trades.len(), 1);
1243        assert_eq!(trades.trades[0].id, 999);
1244        assert_eq!(trades.trades[0].price_mantissa, 100_000_000_000);
1245        assert!(trades.trades[0].is_buyer_maker);
1246        assert!(trades.trades[0].is_best_match);
1247    }
1248
1249    #[rstest]
1250    fn test_decode_trades_empty() {
1251        let header = create_header(2, TRADES_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1252
1253        let mut buf = Vec::new();
1254        buf.extend_from_slice(&header);
1255        buf.push(0); // price_exponent
1256        buf.push(0); // qty_exponent
1257
1258        // Empty trades group
1259        buf.extend_from_slice(&create_group_header(42, 0));
1260
1261        let trades = decode_trades(&buf).unwrap();
1262
1263        assert!(trades.trades.is_empty());
1264    }
1265
1266    #[rstest]
1267    fn test_decode_depth_wrong_template() {
1268        let header = create_header(10, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1269
1270        let mut buf = Vec::new();
1271        buf.extend_from_slice(&header);
1272        buf.extend_from_slice(&[0u8; 10]); // dummy block
1273
1274        let err = decode_depth(&buf).unwrap_err();
1275        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1276    }
1277
1278    #[rstest]
1279    fn test_decode_trades_wrong_template() {
1280        let header = create_header(2, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1281
1282        let mut buf = Vec::new();
1283        buf.extend_from_slice(&header);
1284        buf.extend_from_slice(&[0u8; 2]); // dummy block
1285
1286        let err = decode_trades(&buf).unwrap_err();
1287        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1288    }
1289
1290    fn write_var_string(buf: &mut Vec<u8>, s: &str) {
1291        buf.push(s.len() as u8);
1292        buf.extend_from_slice(s.as_bytes());
1293    }
1294
1295    #[rstest]
1296    fn test_decode_order_valid() {
1297        let header = create_header(
1298            ORDER_BLOCK_LENGTH as u16,
1299            ORDER_TEMPLATE_ID,
1300            SBE_SCHEMA_ID,
1301            SBE_SCHEMA_VERSION,
1302        );
1303
1304        let mut buf = Vec::new();
1305        buf.extend_from_slice(&header);
1306
1307        // Fixed block (153 bytes)
1308        buf.push((-8i8) as u8); // price_exponent
1309        buf.push((-8i8) as u8); // qty_exponent
1310        buf.extend_from_slice(&12345i64.to_le_bytes()); // order_id
1311        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1312        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1313        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty_mantissa
1314        buf.extend_from_slice(&5_000_000i64.to_le_bytes()); // executed_qty_mantissa
1315        buf.extend_from_slice(&500_000_000i64.to_le_bytes()); // cummulative_quote_qty_mantissa
1316        buf.push(1); // status (NEW)
1317        buf.push(1); // time_in_force (GTC)
1318        buf.push(1); // order_type (LIMIT)
1319        buf.push(1); // side (BUY)
1320        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1321        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1322        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1323        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // update_time
1324        buf.push(1); // is_working (true)
1325        buf.extend_from_slice(&1734300000500i64.to_le_bytes()); // working_time
1326        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty_mantissa
1327        buf.push(0); // self_trade_prevention_mode
1328
1329        // Pad to 153 bytes
1330        while buf.len() < 8 + ORDER_BLOCK_LENGTH {
1331            buf.push(0);
1332        }
1333
1334        write_var_string(&mut buf, "BTCUSDT");
1335        write_var_string(&mut buf, "my-order-123");
1336
1337        let order = decode_order(&buf).unwrap();
1338
1339        assert_eq!(order.order_id, 12345);
1340        assert!(order.order_list_id.is_none());
1341        assert_eq!(order.price_exponent, -8);
1342        assert_eq!(order.price_mantissa, 100_000_000_000);
1343        assert!(order.stop_price_mantissa.is_none());
1344        assert!(order.iceberg_qty_mantissa.is_none());
1345        assert!(order.is_working);
1346        assert_eq!(order.working_time, Some(1734300000500));
1347        assert_eq!(order.symbol, "BTCUSDT");
1348        assert_eq!(order.client_order_id, "my-order-123");
1349    }
1350
1351    #[rstest]
1352    fn test_decode_orders_multiple() {
1353        // This test verifies cursor advances correctly through multiple orders
1354        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1355
1356        let mut buf = Vec::new();
1357        buf.extend_from_slice(&header);
1358
1359        // Group header: block_length=162, count=2
1360        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_BLOCK_LENGTH as u16, 2));
1361
1362        // Order 1
1363        let order1_start = buf.len();
1364        buf.push((-8i8) as u8); // price_exponent
1365        buf.push((-8i8) as u8); // qty_exponent
1366        buf.extend_from_slice(&1001i64.to_le_bytes()); // order_id
1367        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1368        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1369        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1370        buf.extend_from_slice(&0i64.to_le_bytes()); // executed_qty
1371        buf.extend_from_slice(&0i64.to_le_bytes()); // cummulative_quote_qty
1372        buf.push(1); // status
1373        buf.push(1); // time_in_force
1374        buf.push(1); // order_type
1375        buf.push(1); // side
1376        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1377        buf.extend_from_slice(&[0u8; 16]); // trailing_delta + trailing_time
1378        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1379        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1380        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // update_time
1381        buf.push(1); // is_working
1382        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // working_time
1383        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty
1384
1385        // Pad to 162 bytes from order start
1386        while buf.len() - order1_start < ORDERS_GROUP_BLOCK_LENGTH {
1387            buf.push(0);
1388        }
1389        write_var_string(&mut buf, "BTCUSDT");
1390        write_var_string(&mut buf, "order-1");
1391
1392        // Order 2
1393        let order2_start = buf.len();
1394        buf.push((-8i8) as u8); // price_exponent
1395        buf.push((-8i8) as u8); // qty_exponent
1396        buf.extend_from_slice(&2002i64.to_le_bytes()); // order_id
1397        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1398        buf.extend_from_slice(&200_000_000_000i64.to_le_bytes()); // price_mantissa
1399        buf.extend_from_slice(&20_000_000i64.to_le_bytes()); // orig_qty
1400        buf.extend_from_slice(&0i64.to_le_bytes()); // executed_qty
1401        buf.extend_from_slice(&0i64.to_le_bytes()); // cummulative_quote_qty
1402        buf.push(1); // status
1403        buf.push(1); // time_in_force
1404        buf.push(1); // order_type
1405        buf.push(2); // side (SELL)
1406        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // stop_price (None)
1407        buf.extend_from_slice(&[0u8; 16]); // trailing_delta + trailing_time
1408        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // iceberg_qty (None)
1409        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // time
1410        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // update_time
1411        buf.push(1); // is_working
1412        buf.extend_from_slice(&1734300001000i64.to_le_bytes()); // working_time
1413        buf.extend_from_slice(&0i64.to_le_bytes()); // orig_quote_order_qty
1414
1415        while buf.len() - order2_start < ORDERS_GROUP_BLOCK_LENGTH {
1416            buf.push(0);
1417        }
1418        write_var_string(&mut buf, "ETHUSDT");
1419        write_var_string(&mut buf, "order-2");
1420
1421        let orders = decode_orders(&buf).unwrap();
1422
1423        assert_eq!(orders.len(), 2);
1424        assert_eq!(orders[0].order_id, 1001);
1425        assert_eq!(orders[0].symbol, "BTCUSDT");
1426        assert_eq!(orders[0].client_order_id, "order-1");
1427        assert_eq!(orders[0].price_mantissa, 100_000_000_000);
1428
1429        assert_eq!(orders[1].order_id, 2002);
1430        assert_eq!(orders[1].symbol, "ETHUSDT");
1431        assert_eq!(orders[1].client_order_id, "order-2");
1432        assert_eq!(orders[1].price_mantissa, 200_000_000_000);
1433    }
1434
1435    #[rstest]
1436    fn test_decode_orders_empty() {
1437        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1438
1439        let mut buf = Vec::new();
1440        buf.extend_from_slice(&header);
1441        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_BLOCK_LENGTH as u16, 0));
1442
1443        let orders = decode_orders(&buf).unwrap();
1444        assert!(orders.is_empty());
1445    }
1446
1447    #[rstest]
1448    fn test_decode_orders_truncated_var_string() {
1449        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1450
1451        let mut buf = Vec::new();
1452        buf.extend_from_slice(&header);
1453        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_BLOCK_LENGTH as u16, 1));
1454
1455        // Pad fixed block to 162 bytes
1456        buf.extend_from_slice(&[0u8; ORDERS_GROUP_BLOCK_LENGTH]);
1457
1458        // Symbol length says 7 bytes but we only provide 3
1459        buf.push(7); // Length prefix claims "BTCUSDT" (7 chars)
1460        buf.extend_from_slice(b"BTC"); // Only 3 bytes - truncated
1461
1462        let err = decode_orders(&buf).unwrap_err();
1463        assert!(matches!(err, SbeDecodeError::BufferTooShort { .. }));
1464    }
1465
1466    #[rstest]
1467    fn test_decode_orders_invalid_utf8() {
1468        let header = create_header(0, ORDERS_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1469
1470        let mut buf = Vec::new();
1471        buf.extend_from_slice(&header);
1472        buf.extend_from_slice(&create_group_header(ORDERS_GROUP_BLOCK_LENGTH as u16, 1));
1473
1474        buf.extend_from_slice(&[0u8; ORDERS_GROUP_BLOCK_LENGTH]);
1475
1476        // Invalid UTF-8 sequence
1477        buf.push(4);
1478        buf.extend_from_slice(&[0xFF, 0xFE, 0x00, 0x01]);
1479
1480        let err = decode_orders(&buf).unwrap_err();
1481        assert!(matches!(err, SbeDecodeError::InvalidUtf8));
1482    }
1483
1484    #[rstest]
1485    fn test_decode_cancel_order_valid() {
1486        let header = create_header(
1487            CANCEL_ORDER_BLOCK_LENGTH as u16,
1488            CANCEL_ORDER_TEMPLATE_ID,
1489            SBE_SCHEMA_ID,
1490            SBE_SCHEMA_VERSION,
1491        );
1492
1493        let mut buf = Vec::new();
1494        buf.extend_from_slice(&header);
1495
1496        buf.push((-8i8) as u8); // price_exponent
1497        buf.push((-8i8) as u8); // qty_exponent
1498        buf.extend_from_slice(&99999i64.to_le_bytes()); // order_id
1499        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1500        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // transact_time
1501        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price_mantissa
1502        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // orig_qty
1503        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // executed_qty
1504        buf.extend_from_slice(&1_000_000_000i64.to_le_bytes()); // cummulative_quote_qty
1505        buf.push(4); // status (CANCELED)
1506        buf.push(1); // time_in_force
1507        buf.push(1); // order_type
1508        buf.push(1); // side
1509        buf.push(0); // self_trade_prevention_mode
1510
1511        // Pad to block length
1512        while buf.len() < 8 + CANCEL_ORDER_BLOCK_LENGTH {
1513            buf.push(0);
1514        }
1515
1516        write_var_string(&mut buf, "BTCUSDT");
1517        write_var_string(&mut buf, "orig-client-id");
1518        write_var_string(&mut buf, "new-client-id");
1519
1520        let cancel = decode_cancel_order(&buf).unwrap();
1521
1522        assert_eq!(cancel.order_id, 99999);
1523        assert!(cancel.order_list_id.is_none());
1524        assert_eq!(cancel.symbol, "BTCUSDT");
1525        assert_eq!(cancel.orig_client_order_id, "orig-client-id");
1526        assert_eq!(cancel.client_order_id, "new-client-id");
1527    }
1528
1529    #[rstest]
1530    fn test_decode_account_with_balances() {
1531        let header = create_header(
1532            ACCOUNT_BLOCK_LENGTH as u16,
1533            ACCOUNT_TEMPLATE_ID,
1534            SBE_SCHEMA_ID,
1535            SBE_SCHEMA_VERSION,
1536        );
1537
1538        let mut buf = Vec::new();
1539        buf.extend_from_slice(&header);
1540
1541        // Fixed block (64 bytes)
1542        buf.push((-8i8) as u8); // commission_exponent
1543        buf.extend_from_slice(&100_000i64.to_le_bytes()); // maker_commission
1544        buf.extend_from_slice(&100_000i64.to_le_bytes()); // taker_commission
1545        buf.extend_from_slice(&0i64.to_le_bytes()); // buyer_commission
1546        buf.extend_from_slice(&0i64.to_le_bytes()); // seller_commission
1547        buf.push(1); // can_trade
1548        buf.push(1); // can_withdraw
1549        buf.push(1); // can_deposit
1550        buf.push(0); // brokered
1551        buf.push(0); // require_self_trade_prevention
1552        buf.push(0); // prevent_sor
1553        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // update_time
1554        buf.push(1); // account_type (SPOT)
1555
1556        // Pad to 64 bytes
1557        while buf.len() < 8 + ACCOUNT_BLOCK_LENGTH {
1558            buf.push(0);
1559        }
1560
1561        // Balances group: 2 balances
1562        buf.extend_from_slice(&create_group_header(BALANCE_BLOCK_LENGTH, 2));
1563
1564        // Balance 1: BTC
1565        buf.push((-8i8) as u8); // exponent
1566        buf.extend_from_slice(&100_000_000i64.to_le_bytes()); // free (1.0 BTC)
1567        buf.extend_from_slice(&50_000_000i64.to_le_bytes()); // locked (0.5 BTC)
1568        write_var_string(&mut buf, "BTC");
1569
1570        // Balance 2: USDT
1571        buf.push((-8i8) as u8); // exponent
1572        buf.extend_from_slice(&1_000_000_000_000i64.to_le_bytes()); // free (10000 USDT)
1573        buf.extend_from_slice(&0i64.to_le_bytes()); // locked
1574        write_var_string(&mut buf, "USDT");
1575
1576        let account = decode_account(&buf).unwrap();
1577
1578        assert!(account.can_trade);
1579        assert!(account.can_withdraw);
1580        assert!(account.can_deposit);
1581        assert_eq!(account.balances.len(), 2);
1582        assert_eq!(account.balances[0].asset, "BTC");
1583        assert_eq!(account.balances[0].free_mantissa, 100_000_000);
1584        assert_eq!(account.balances[0].locked_mantissa, 50_000_000);
1585        assert_eq!(account.balances[1].asset, "USDT");
1586        assert_eq!(account.balances[1].free_mantissa, 1_000_000_000_000);
1587    }
1588
1589    #[rstest]
1590    fn test_decode_account_empty_balances() {
1591        let header = create_header(
1592            ACCOUNT_BLOCK_LENGTH as u16,
1593            ACCOUNT_TEMPLATE_ID,
1594            SBE_SCHEMA_ID,
1595            SBE_SCHEMA_VERSION,
1596        );
1597
1598        let mut buf = Vec::new();
1599        buf.extend_from_slice(&header);
1600
1601        // Minimal fixed block
1602        buf.push((-8i8) as u8);
1603        buf.extend_from_slice(&[0u8; 63]); // Rest of fixed block
1604
1605        // Empty balances group
1606        buf.extend_from_slice(&create_group_header(BALANCE_BLOCK_LENGTH, 0));
1607
1608        let account = decode_account(&buf).unwrap();
1609        assert!(account.balances.is_empty());
1610    }
1611
1612    #[rstest]
1613    fn test_decode_account_trades_multiple() {
1614        let header = create_header(
1615            0,
1616            ACCOUNT_TRADES_TEMPLATE_ID,
1617            SBE_SCHEMA_ID,
1618            SBE_SCHEMA_VERSION,
1619        );
1620
1621        let mut buf = Vec::new();
1622        buf.extend_from_slice(&header);
1623
1624        // Group header: 2 trades
1625        buf.extend_from_slice(&create_group_header(ACCOUNT_TRADE_BLOCK_LENGTH, 2));
1626
1627        // Trade 1
1628        buf.push((-8i8) as u8); // price_exponent
1629        buf.push((-8i8) as u8); // qty_exponent
1630        buf.push((-8i8) as u8); // commission_exponent
1631        buf.extend_from_slice(&1001i64.to_le_bytes()); // id
1632        buf.extend_from_slice(&5001i64.to_le_bytes()); // order_id
1633        buf.extend_from_slice(&i64::MIN.to_le_bytes()); // order_list_id (None)
1634        buf.extend_from_slice(&100_000_000_000i64.to_le_bytes()); // price
1635        buf.extend_from_slice(&10_000_000i64.to_le_bytes()); // qty
1636        buf.extend_from_slice(&1_000_000_000_000i64.to_le_bytes()); // quote_qty
1637        buf.extend_from_slice(&100_000i64.to_le_bytes()); // commission
1638        buf.extend_from_slice(&1734300000000i64.to_le_bytes()); // time
1639        buf.push(1); // is_buyer
1640        buf.push(0); // is_maker
1641        buf.push(1); // is_best_match
1642        write_var_string(&mut buf, "BTCUSDT");
1643        write_var_string(&mut buf, "BNB");
1644
1645        // Trade 2
1646        buf.push((-8i8) as u8);
1647        buf.push((-8i8) as u8);
1648        buf.push((-8i8) as u8);
1649        buf.extend_from_slice(&1002i64.to_le_bytes());
1650        buf.extend_from_slice(&5002i64.to_le_bytes());
1651        buf.extend_from_slice(&i64::MIN.to_le_bytes());
1652        buf.extend_from_slice(&200_000_000_000i64.to_le_bytes());
1653        buf.extend_from_slice(&5_000_000i64.to_le_bytes());
1654        buf.extend_from_slice(&1_000_000_000_000i64.to_le_bytes());
1655        buf.extend_from_slice(&50_000i64.to_le_bytes());
1656        buf.extend_from_slice(&1734300001000i64.to_le_bytes());
1657        buf.push(0); // is_buyer (false = seller)
1658        buf.push(1); // is_maker
1659        buf.push(1); // is_best_match
1660        write_var_string(&mut buf, "ETHUSDT");
1661        write_var_string(&mut buf, "USDT");
1662
1663        let trades = decode_account_trades(&buf).unwrap();
1664
1665        assert_eq!(trades.len(), 2);
1666        assert_eq!(trades[0].id, 1001);
1667        assert_eq!(trades[0].order_id, 5001);
1668        assert!(trades[0].order_list_id.is_none());
1669        assert_eq!(trades[0].symbol, "BTCUSDT");
1670        assert_eq!(trades[0].commission_asset, "BNB");
1671        assert!(trades[0].is_buyer);
1672        assert!(!trades[0].is_maker);
1673
1674        assert_eq!(trades[1].id, 1002);
1675        assert_eq!(trades[1].symbol, "ETHUSDT");
1676        assert_eq!(trades[1].commission_asset, "USDT");
1677        assert!(!trades[1].is_buyer);
1678        assert!(trades[1].is_maker);
1679    }
1680
1681    #[rstest]
1682    fn test_decode_account_trades_empty() {
1683        let header = create_header(
1684            0,
1685            ACCOUNT_TRADES_TEMPLATE_ID,
1686            SBE_SCHEMA_ID,
1687            SBE_SCHEMA_VERSION,
1688        );
1689
1690        let mut buf = Vec::new();
1691        buf.extend_from_slice(&header);
1692        buf.extend_from_slice(&create_group_header(ACCOUNT_TRADE_BLOCK_LENGTH, 0));
1693
1694        let trades = decode_account_trades(&buf).unwrap();
1695        assert!(trades.is_empty());
1696    }
1697
1698    #[rstest]
1699    fn test_decode_exchange_info_single_symbol() {
1700        let header = create_header(
1701            0,
1702            EXCHANGE_INFO_TEMPLATE_ID,
1703            SBE_SCHEMA_ID,
1704            SBE_SCHEMA_VERSION,
1705        );
1706
1707        let mut buf = Vec::new();
1708        buf.extend_from_slice(&header);
1709
1710        // Empty rate_limits group
1711        buf.extend_from_slice(&create_group_header(11, 0));
1712
1713        // Empty exchange_filters group
1714        buf.extend_from_slice(&create_group_header(0, 0));
1715
1716        // Symbols group: 1 symbol with block_length=19
1717        buf.extend_from_slice(&create_group_header(SYMBOL_BLOCK_LENGTH as u16, 1));
1718
1719        // Fixed block (19 bytes)
1720        buf.push(0); // status (Trading)
1721        buf.push(8); // base_asset_precision
1722        buf.push(8); // quote_asset_precision
1723        buf.push(8); // base_commission_precision
1724        buf.push(8); // quote_commission_precision
1725        buf.extend_from_slice(&0b0000_0111u16.to_le_bytes()); // order_types (MARKET|LIMIT|STOP_LOSS)
1726        buf.push(1); // iceberg_allowed (True)
1727        buf.push(1); // oco_allowed (True)
1728        buf.push(0); // oto_allowed (False)
1729        buf.push(1); // quote_order_qty_market_allowed (True)
1730        buf.push(1); // allow_trailing_stop (True)
1731        buf.push(1); // cancel_replace_allowed (True)
1732        buf.push(0); // amend_allowed (False)
1733        buf.push(1); // is_spot_trading_allowed (True)
1734        buf.push(0); // is_margin_trading_allowed (False)
1735        buf.push(0); // default_self_trade_prevention_mode
1736        buf.push(0); // allowed_self_trade_prevention_modes
1737        buf.push(0); // peg_instructions_allowed
1738
1739        // Filters nested group: 0 filters (SBE binary filters are skipped)
1740        buf.extend_from_slice(&create_group_header(0, 0));
1741
1742        // Permission sets nested group: 1 set with 1 permission
1743        buf.extend_from_slice(&create_group_header(0, 1));
1744        buf.extend_from_slice(&create_group_header(0, 1));
1745        write_var_string(&mut buf, "SPOT");
1746
1747        // Variable-length strings
1748        write_var_string(&mut buf, "BTCUSDT");
1749        write_var_string(&mut buf, "BTC");
1750        write_var_string(&mut buf, "USDT");
1751
1752        let info = decode_exchange_info(&buf).unwrap();
1753
1754        assert_eq!(info.symbols.len(), 1);
1755        let symbol = &info.symbols[0];
1756        assert_eq!(symbol.symbol, "BTCUSDT");
1757        assert_eq!(symbol.base_asset, "BTC");
1758        assert_eq!(symbol.quote_asset, "USDT");
1759        assert_eq!(symbol.base_asset_precision, 8);
1760        assert_eq!(symbol.quote_asset_precision, 8);
1761        assert_eq!(symbol.status, 0); // Trading
1762        assert_eq!(symbol.order_types, 0b0000_0111);
1763        assert!(symbol.iceberg_allowed);
1764        assert!(symbol.oco_allowed);
1765        assert!(!symbol.oto_allowed);
1766        assert!(symbol.quote_order_qty_market_allowed);
1767        assert!(symbol.allow_trailing_stop);
1768        assert!(symbol.cancel_replace_allowed);
1769        assert!(!symbol.amend_allowed);
1770        assert!(symbol.is_spot_trading_allowed);
1771        assert!(!symbol.is_margin_trading_allowed);
1772        assert!(symbol.filters.price_filter.is_none()); // No filters in test data
1773        assert!(symbol.filters.lot_size_filter.is_none());
1774        assert_eq!(symbol.permissions.len(), 1);
1775        assert_eq!(symbol.permissions[0], vec!["SPOT"]);
1776    }
1777
1778    #[rstest]
1779    fn test_decode_exchange_info_empty() {
1780        let header = create_header(
1781            0,
1782            EXCHANGE_INFO_TEMPLATE_ID,
1783            SBE_SCHEMA_ID,
1784            SBE_SCHEMA_VERSION,
1785        );
1786
1787        let mut buf = Vec::new();
1788        buf.extend_from_slice(&header);
1789
1790        // Empty rate_limits group
1791        buf.extend_from_slice(&create_group_header(11, 0));
1792
1793        // Empty exchange_filters group
1794        buf.extend_from_slice(&create_group_header(0, 0));
1795
1796        // Empty symbols group
1797        buf.extend_from_slice(&create_group_header(SYMBOL_BLOCK_LENGTH as u16, 0));
1798
1799        let info = decode_exchange_info(&buf).unwrap();
1800        assert!(info.symbols.is_empty());
1801    }
1802
1803    #[rstest]
1804    fn test_decode_exchange_info_wrong_template() {
1805        let header = create_header(0, PING_TEMPLATE_ID, SBE_SCHEMA_ID, SBE_SCHEMA_VERSION);
1806
1807        let mut buf = Vec::new();
1808        buf.extend_from_slice(&header);
1809
1810        let err = decode_exchange_info(&buf).unwrap_err();
1811        assert!(matches!(err, SbeDecodeError::UnknownTemplateId(101)));
1812    }
1813
1814    #[rstest]
1815    fn test_decode_exchange_info_multiple_symbols() {
1816        let header = create_header(
1817            0,
1818            EXCHANGE_INFO_TEMPLATE_ID,
1819            SBE_SCHEMA_ID,
1820            SBE_SCHEMA_VERSION,
1821        );
1822
1823        let mut buf = Vec::new();
1824        buf.extend_from_slice(&header);
1825
1826        // Empty rate_limits group
1827        buf.extend_from_slice(&create_group_header(11, 0));
1828
1829        // Empty exchange_filters group
1830        buf.extend_from_slice(&create_group_header(0, 0));
1831
1832        // Symbols group: 2 symbols
1833        buf.extend_from_slice(&create_group_header(SYMBOL_BLOCK_LENGTH as u16, 2));
1834
1835        // Symbol 1: BTCUSDT
1836        buf.push(0); // status
1837        buf.push(8); // base_asset_precision
1838        buf.push(8); // quote_asset_precision
1839        buf.push(8); // base_commission_precision
1840        buf.push(8); // quote_commission_precision
1841        buf.extend_from_slice(&0b0000_0011u16.to_le_bytes()); // order_types
1842        buf.push(1); // iceberg_allowed
1843        buf.push(1); // oco_allowed
1844        buf.push(0); // oto_allowed
1845        buf.push(1); // quote_order_qty_market_allowed
1846        buf.push(1); // allow_trailing_stop
1847        buf.push(1); // cancel_replace_allowed
1848        buf.push(0); // amend_allowed
1849        buf.push(1); // is_spot_trading_allowed
1850        buf.push(0); // is_margin_trading_allowed
1851        buf.push(0); // default_self_trade_prevention_mode
1852        buf.push(0); // allowed_self_trade_prevention_modes
1853        buf.push(0); // peg_instructions_allowed
1854        buf.extend_from_slice(&create_group_header(0, 0)); // No filters
1855        buf.extend_from_slice(&create_group_header(0, 0)); // No permission sets
1856        write_var_string(&mut buf, "BTCUSDT");
1857        write_var_string(&mut buf, "BTC");
1858        write_var_string(&mut buf, "USDT");
1859
1860        // Symbol 2: ETHUSDT
1861        buf.push(0); // status
1862        buf.push(8); // base_asset_precision
1863        buf.push(8); // quote_asset_precision
1864        buf.push(8); // base_commission_precision
1865        buf.push(8); // quote_commission_precision
1866        buf.extend_from_slice(&0b0000_0011u16.to_le_bytes()); // order_types
1867        buf.push(1); // iceberg_allowed
1868        buf.push(1); // oco_allowed
1869        buf.push(0); // oto_allowed
1870        buf.push(1); // quote_order_qty_market_allowed
1871        buf.push(1); // allow_trailing_stop
1872        buf.push(1); // cancel_replace_allowed
1873        buf.push(0); // amend_allowed
1874        buf.push(1); // is_spot_trading_allowed
1875        buf.push(1); // is_margin_trading_allowed
1876        buf.push(0); // default_self_trade_prevention_mode
1877        buf.push(0); // allowed_self_trade_prevention_modes
1878        buf.push(0); // peg_instructions_allowed
1879        buf.extend_from_slice(&create_group_header(0, 0)); // No filters
1880        buf.extend_from_slice(&create_group_header(0, 0)); // No permission sets
1881        write_var_string(&mut buf, "ETHUSDT");
1882        write_var_string(&mut buf, "ETH");
1883        write_var_string(&mut buf, "USDT");
1884
1885        let info = decode_exchange_info(&buf).unwrap();
1886
1887        assert_eq!(info.symbols.len(), 2);
1888        assert_eq!(info.symbols[0].symbol, "BTCUSDT");
1889        assert_eq!(info.symbols[0].base_asset, "BTC");
1890        assert!(!info.symbols[0].is_margin_trading_allowed);
1891
1892        assert_eq!(info.symbols[1].symbol, "ETHUSDT");
1893        assert_eq!(info.symbols[1].base_asset, "ETH");
1894        assert!(info.symbols[1].is_margin_trading_allowed);
1895    }
1896}