Skip to main content

nautilus_binance/spot/websocket/trading/
decode_sbe.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! SBE decoders for Binance Spot user data stream events.
17//!
18//! Decodes templates 601 (BalanceUpdateEvent), 603 (ExecutionReportEvent), and
19//! 607 (OutboundAccountPositionEvent) from schema 3:5 binary payloads into the
20//! venue-level structs defined in [`super::user_data`].
21//! The existing JSON parse functions in [`super::parse`] then convert these to Nautilus types.
22
23use rust_decimal::Decimal;
24use ustr::Ustr;
25
26use super::user_data::{
27    BinanceSpotAccountPositionMsg, BinanceSpotBalanceEntry, BinanceSpotBalanceUpdateMsg,
28    BinanceSpotExecutionReport, BinanceSpotExecutionType,
29};
30use crate::{
31    common::enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
32    spot::sbe::spot::{
33        ReadBuf, balance_update_event_codec, bool_enum, execution_report_event_codec,
34        execution_type, expiry_reason, group_size_encoding_codec, message_header_codec, order_side,
35        order_status, order_type, outbound_account_position_event_codec, time_in_force,
36    },
37};
38
39const HEADER_LEN: usize = message_header_codec::ENCODED_LENGTH;
40const ACCOUNT_POSITION_BLOCK_LENGTH_V0: usize = 16;
41const ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH: usize = 17;
42const BALANCE_UPDATE_BLOCK_LENGTH_V0: usize = 25;
43
44// Historical block lengths are not exposed by the generated codec
45const EXECUTION_REPORT_BLOCK_LENGTH_V0: usize = 268;
46const EXECUTION_REPORT_BLOCK_LENGTH_V1: usize = 281;
47const EXECUTION_REPORT_BLOCK_LENGTH_V3: usize = 282;
48const EXECUTION_REPORT_VAR_DATA_FIELDS: [&str; 6] = [
49    "symbol",
50    "client_order_id",
51    "orig_client_order_id",
52    "commission_asset",
53    "reject_reason",
54    "counter_symbol",
55];
56
57/// Decodes an SBE ExecutionReportEvent (template 603) into a [`BinanceSpotExecutionReport`].
58///
59/// The input buffer must include the 8-byte SBE message header.
60///
61/// # Errors
62///
63/// Returns an error if the buffer is too short, the template ID is wrong, the schema ID does not
64/// match, or variable-length data is malformed.
65pub fn decode_execution_report(data: &[u8]) -> anyhow::Result<BinanceSpotExecutionReport> {
66    if data.len() < HEADER_LEN {
67        anyhow::bail!(
68            "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
69            data.len()
70        );
71    }
72
73    let buf = ReadBuf::new(data);
74    let block_length = buf.get_u16_at(0);
75    let template_id = buf.get_u16_at(2);
76    let schema_id = buf.get_u16_at(4);
77    let version = buf.get_u16_at(6);
78
79    if template_id != execution_report_event_codec::SBE_TEMPLATE_ID {
80        anyhow::bail!(
81            "Wrong template ID: expected {}, received {template_id}",
82            execution_report_event_codec::SBE_TEMPLATE_ID
83        );
84    }
85
86    if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
87        anyhow::bail!(
88            "Wrong schema ID: expected {}, received {schema_id}",
89            crate::spot::sbe::spot::SBE_SCHEMA_ID
90        );
91    }
92
93    let min_block_len = execution_report_min_block_length(version);
94    if usize::from(block_length) < min_block_len {
95        anyhow::bail!(
96            "SBE execution report block length too short: expected at least {min_block_len}, was {block_length}"
97        );
98    }
99
100    let min_len = HEADER_LEN + usize::from(block_length);
101    if data.len() < min_len {
102        anyhow::bail!(
103            "Buffer too short for fixed block: expected {min_len}, was {}",
104            data.len()
105        );
106    }
107
108    let mut field_offset = min_len;
109    for field in EXECUTION_REPORT_VAR_DATA_FIELDS {
110        let Some(length) = data.get(field_offset) else {
111            anyhow::bail!(
112                "Buffer too short for {field} length: expected {}, was {}",
113                field_offset + 1,
114                data.len()
115            );
116        };
117        let expected_len = field_offset + 1 + usize::from(*length);
118        if data.len() < expected_len {
119            anyhow::bail!(
120                "Buffer too short for {field}: expected {expected_len}, was {}",
121                data.len()
122            );
123        }
124        field_offset = expected_len;
125    }
126
127    let mut dec = execution_report_event_codec::ExecutionReportEventDecoder::default().wrap(
128        buf,
129        HEADER_LEN,
130        block_length,
131        version,
132    );
133
134    let price_exp = dec.price_exponent();
135    let qty_exp = dec.qty_exponent();
136    let commission_exp = dec.commission_exponent();
137
138    let event_time_us = dec.event_time();
139    let transact_time_us = dec.transact_time();
140    let order_creation_time_us = dec.order_creation_time();
141    let order_id = dec.order_id();
142    let trade_id = dec.trade_id().unwrap_or(-1);
143
144    let execution_type = map_execution_type(dec.execution_type())?;
145    let order_status = map_order_status(dec.order_status());
146    let side = map_side(dec.side())?;
147    let time_in_force = map_time_in_force(dec.time_in_force());
148    let order_type_str = map_order_type(dec.order_type());
149    let expiry_reason = map_expiry_reason(dec.expiry_reason());
150    let is_working = dec.is_working() == bool_enum::BoolEnum::True;
151    let is_maker = dec.is_maker() == bool_enum::BoolEnum::True;
152
153    let price_mantissa = dec.price();
154    let orig_qty_mantissa = dec.orig_qty();
155    let stop_price_mantissa = dec.stop_price();
156    let last_qty_mantissa = dec.last_qty();
157    let last_price_mantissa = dec.last_price();
158    let executed_qty_mantissa = dec.executed_qty();
159    let cummulative_quote_qty_mantissa = dec.cummulative_quote_qty();
160    let commission_mantissa = dec.commission();
161
162    // Variable-length fields must be read in schema order.
163    // Each field is converted to an owned String immediately to release the borrow.
164    let symbol = {
165        let coords = dec.symbol_decoder();
166        String::from_utf8_lossy(dec.symbol_slice(coords)).into_owned()
167    };
168
169    let client_order_id = {
170        let coords = dec.client_order_id_decoder();
171        String::from_utf8_lossy(dec.client_order_id_slice(coords)).into_owned()
172    };
173
174    let orig_client_order_id = {
175        let coords = dec.orig_client_order_id_decoder();
176        let s = String::from_utf8_lossy(dec.orig_client_order_id_slice(coords)).into_owned();
177        if s.is_empty() { None } else { Some(s) }
178    };
179
180    let commission_asset = {
181        let coords = dec.commission_asset_decoder();
182        let bytes = dec.commission_asset_slice(coords);
183        if bytes.is_empty() {
184            None
185        } else {
186            Some(Ustr::from(&String::from_utf8_lossy(bytes)))
187        }
188    };
189
190    let reject_reason = {
191        let coords = dec.reject_reason_decoder();
192        String::from_utf8_lossy(dec.reject_reason_slice(coords)).into_owned()
193    };
194
195    // counter_symbol - read to advance cursor, not used in venue struct
196    let _counter_symbol_coords = dec.counter_symbol_decoder();
197
198    Ok(BinanceSpotExecutionReport {
199        event_type: "executionReport".to_string(),
200        event_time: us_to_ms(event_time_us),
201        symbol: Ustr::from(&symbol),
202        client_order_id,
203        side,
204        order_type: order_type_str.to_string(),
205        time_in_force,
206        original_qty: mantissa_to_decimal_string(orig_qty_mantissa, qty_exp),
207        price: mantissa_to_decimal_string(price_mantissa, price_exp),
208        stop_price: mantissa_to_decimal_string(stop_price_mantissa, price_exp),
209        execution_type,
210        order_status,
211        reject_reason,
212        order_id,
213        last_filled_qty: mantissa_to_decimal_string(last_qty_mantissa, qty_exp),
214        cumulative_filled_qty: mantissa_to_decimal_string(executed_qty_mantissa, qty_exp),
215        last_filled_price: mantissa_to_decimal_string(last_price_mantissa, price_exp),
216        commission: mantissa_to_decimal_string(commission_mantissa, commission_exp),
217        commission_asset,
218        transaction_time: us_to_ms(transact_time_us),
219        trade_id,
220        is_working,
221        is_maker,
222        order_creation_time: order_creation_time_us.map_or(0, us_to_ms),
223        cumulative_quote_qty: mantissa_to_decimal_string(
224            cummulative_quote_qty_mantissa,
225            price_exp + qty_exp,
226        ),
227        original_client_order_id: orig_client_order_id,
228        expiry_reason,
229    })
230}
231
232fn execution_report_min_block_length(version: u16) -> usize {
233    match version {
234        0 => EXECUTION_REPORT_BLOCK_LENGTH_V0,
235        1 | 2 => EXECUTION_REPORT_BLOCK_LENGTH_V1,
236        3..=5 => EXECUTION_REPORT_BLOCK_LENGTH_V3,
237        _ => usize::from(execution_report_event_codec::SBE_BLOCK_LENGTH),
238    }
239}
240
241/// Decodes an SBE OutboundAccountPositionEvent (template 607) into a
242/// [`BinanceSpotAccountPositionMsg`].
243///
244/// The input buffer must include the 8-byte SBE message header.
245///
246/// # Errors
247///
248/// Returns an error if the buffer is too short, the template ID is wrong, the schema ID does not
249/// match, or balances framing is malformed.
250pub fn decode_account_position(data: &[u8]) -> anyhow::Result<BinanceSpotAccountPositionMsg> {
251    if data.len() < HEADER_LEN {
252        anyhow::bail!(
253            "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
254            data.len()
255        );
256    }
257
258    let buf = ReadBuf::new(data);
259    let block_length = buf.get_u16_at(0);
260    let template_id = buf.get_u16_at(2);
261    let schema_id = buf.get_u16_at(4);
262    let version = buf.get_u16_at(6);
263
264    if template_id != outbound_account_position_event_codec::SBE_TEMPLATE_ID {
265        anyhow::bail!(
266            "Wrong template ID: expected {}, received {template_id}",
267            outbound_account_position_event_codec::SBE_TEMPLATE_ID
268        );
269    }
270
271    if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
272        anyhow::bail!(
273            "Wrong schema ID: expected {}, received {schema_id}",
274            crate::spot::sbe::spot::SBE_SCHEMA_ID
275        );
276    }
277
278    let min_block_len = if version == 0 {
279        ACCOUNT_POSITION_BLOCK_LENGTH_V0
280    } else {
281        usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
282    };
283
284    if usize::from(block_length) < min_block_len {
285        anyhow::bail!(
286            "SBE account position block length too short: expected at least {min_block_len}, was {block_length}"
287        );
288    }
289
290    let min_len = HEADER_LEN + usize::from(block_length);
291    if data.len() < min_len {
292        anyhow::bail!(
293            "Buffer too short for fixed block: expected {min_len}, was {}",
294            data.len()
295        );
296    }
297
298    let group_header_end = min_len + group_size_encoding_codec::ENCODED_LENGTH;
299    if data.len() < group_header_end {
300        anyhow::bail!(
301            "Buffer too short for balances group header: expected {group_header_end}, was {}",
302            data.len()
303        );
304    }
305
306    let entry_block_length = usize::from(buf.get_u16_at(min_len));
307    if entry_block_length < ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH {
308        anyhow::bail!(
309            "SBE balances entry block length too short: expected at least {ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH}, was {entry_block_length}"
310        );
311    }
312
313    let count = buf.get_u32_at(min_len + 2);
314    let mut entry_offset = group_header_end;
315
316    for index in 0..count {
317        let entry_end = entry_offset + entry_block_length;
318        if data.len() < entry_end {
319            anyhow::bail!(
320                "Buffer too short for balances entry {index}: expected {entry_end}, was {}",
321                data.len()
322            );
323        }
324
325        entry_offset = validate_asset(data, entry_end)?;
326    }
327
328    let dec = outbound_account_position_event_codec::OutboundAccountPositionEventDecoder::default()
329        .wrap(buf, HEADER_LEN, block_length, version);
330
331    let event_time_us = dec.event_time();
332    let update_time_us = dec.update_time();
333
334    let mut balances_dec = dec.balances_decoder();
335    let count = balances_dec.count() as usize;
336    let mut balances = Vec::with_capacity(count);
337
338    while let Some(_idx) = balances_dec
339        .advance()
340        .map_err(|e| anyhow::anyhow!("Failed to advance balances group: {e:?}"))?
341    {
342        let exponent = balances_dec.exponent();
343        let free_mantissa = balances_dec.free();
344        let locked_mantissa = balances_dec.locked();
345
346        let asset_coords = balances_dec.asset_decoder();
347        let asset_bytes = balances_dec.asset_slice(asset_coords);
348        let asset = Ustr::from(&String::from_utf8_lossy(asset_bytes));
349
350        balances.push(BinanceSpotBalanceEntry {
351            asset,
352            free: mantissa_to_decimal(free_mantissa, exponent),
353            locked: mantissa_to_decimal(locked_mantissa, exponent),
354        });
355    }
356
357    Ok(BinanceSpotAccountPositionMsg {
358        event_type: "outboundAccountPosition".to_string(),
359        event_time: us_to_ms(event_time_us),
360        last_update_time: us_to_ms(update_time_us),
361        balances,
362    })
363}
364
365/// Decodes an SBE BalanceUpdateEvent (template 601) into a [`BinanceSpotBalanceUpdateMsg`].
366///
367/// The input buffer must include the 8-byte SBE message header.
368///
369/// # Errors
370///
371/// Returns an error if the buffer is too short, the template ID is wrong, the schema ID does not
372/// match, or asset framing is malformed.
373pub fn decode_balance_update(data: &[u8]) -> anyhow::Result<BinanceSpotBalanceUpdateMsg> {
374    if data.len() < HEADER_LEN {
375        anyhow::bail!(
376            "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
377            data.len()
378        );
379    }
380
381    let buf = ReadBuf::new(data);
382    let block_length = buf.get_u16_at(0);
383    let template_id = buf.get_u16_at(2);
384    let schema_id = buf.get_u16_at(4);
385    let version = buf.get_u16_at(6);
386
387    if template_id != balance_update_event_codec::SBE_TEMPLATE_ID {
388        anyhow::bail!(
389            "Wrong template ID: expected {}, received {template_id}",
390            balance_update_event_codec::SBE_TEMPLATE_ID
391        );
392    }
393
394    if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
395        anyhow::bail!(
396            "Wrong schema ID: expected {}, received {schema_id}",
397            crate::spot::sbe::spot::SBE_SCHEMA_ID
398        );
399    }
400
401    let min_block_len = if version == 0 {
402        BALANCE_UPDATE_BLOCK_LENGTH_V0
403    } else {
404        usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH)
405    };
406
407    if usize::from(block_length) < min_block_len {
408        anyhow::bail!(
409            "SBE balance update block length too short: expected at least {min_block_len}, was {block_length}"
410        );
411    }
412
413    let min_len = HEADER_LEN + usize::from(block_length);
414    if data.len() < min_len {
415        anyhow::bail!(
416            "Buffer too short for fixed block: expected {min_len}, was {}",
417            data.len()
418        );
419    }
420
421    validate_asset(data, min_len)?;
422
423    let mut dec = balance_update_event_codec::BalanceUpdateEventDecoder::default().wrap(
424        buf,
425        HEADER_LEN,
426        block_length,
427        version,
428    );
429
430    let event_time_us = dec.event_time();
431    let clear_time_us = dec.clear_time().unwrap_or(0);
432    let qty_exponent = dec.qty_exponent();
433    let free_qty_delta = dec.free_qty_delta();
434
435    let asset = {
436        let coords = dec.asset_decoder();
437        String::from_utf8_lossy(dec.asset_slice(coords)).into_owned()
438    };
439
440    Ok(BinanceSpotBalanceUpdateMsg {
441        event_type: "balanceUpdate".to_string(),
442        event_time: us_to_ms(event_time_us),
443        asset: Ustr::from(&asset),
444        delta: mantissa_to_decimal_string(free_qty_delta, qty_exponent),
445        clear_time: us_to_ms(clear_time_us),
446    })
447}
448
449fn validate_asset(data: &[u8], offset: usize) -> anyhow::Result<usize> {
450    let Some(length) = data.get(offset) else {
451        anyhow::bail!(
452            "Buffer too short for asset length: expected {}, was {}",
453            offset + 1,
454            data.len()
455        );
456    };
457
458    let expected_len = offset + 1 + usize::from(*length);
459    if data.len() < expected_len {
460        anyhow::bail!(
461            "Buffer too short for asset: expected {expected_len}, was {}",
462            data.len()
463        );
464    }
465
466    Ok(expected_len)
467}
468
469fn map_execution_type(
470    et: execution_type::ExecutionType,
471) -> anyhow::Result<BinanceSpotExecutionType> {
472    match et {
473        execution_type::ExecutionType::New => Ok(BinanceSpotExecutionType::New),
474        execution_type::ExecutionType::Canceled => Ok(BinanceSpotExecutionType::Canceled),
475        execution_type::ExecutionType::Replaced => Ok(BinanceSpotExecutionType::Replaced),
476        execution_type::ExecutionType::Rejected => Ok(BinanceSpotExecutionType::Rejected),
477        execution_type::ExecutionType::Trade => Ok(BinanceSpotExecutionType::Trade),
478        execution_type::ExecutionType::Expired => Ok(BinanceSpotExecutionType::Expired),
479        execution_type::ExecutionType::TradePrevention => {
480            Ok(BinanceSpotExecutionType::TradePrevention)
481        }
482        _ => anyhow::bail!("Unsupported SBE execution type: {et}"),
483    }
484}
485
486fn map_order_status(os: order_status::OrderStatus) -> BinanceOrderStatus {
487    match os {
488        order_status::OrderStatus::New => BinanceOrderStatus::New,
489        order_status::OrderStatus::PartiallyFilled => BinanceOrderStatus::PartiallyFilled,
490        order_status::OrderStatus::Filled => BinanceOrderStatus::Filled,
491        order_status::OrderStatus::Canceled => BinanceOrderStatus::Canceled,
492        order_status::OrderStatus::PendingCancel => BinanceOrderStatus::PendingCancel,
493        order_status::OrderStatus::Rejected => BinanceOrderStatus::Rejected,
494        order_status::OrderStatus::Expired => BinanceOrderStatus::Expired,
495        order_status::OrderStatus::ExpiredInMatch => BinanceOrderStatus::ExpiredInMatch,
496        _ => BinanceOrderStatus::Unknown,
497    }
498}
499
500fn map_side(side: order_side::OrderSide) -> anyhow::Result<BinanceSide> {
501    match side {
502        order_side::OrderSide::Buy => Ok(BinanceSide::Buy),
503        order_side::OrderSide::Sell => Ok(BinanceSide::Sell),
504        _ => anyhow::bail!("Unsupported SBE order side: {side}"),
505    }
506}
507
508fn map_time_in_force(tif: time_in_force::TimeInForce) -> BinanceTimeInForce {
509    match tif {
510        time_in_force::TimeInForce::Gtc => BinanceTimeInForce::Gtc,
511        time_in_force::TimeInForce::Ioc => BinanceTimeInForce::Ioc,
512        time_in_force::TimeInForce::Fok => BinanceTimeInForce::Fok,
513        _ => BinanceTimeInForce::Unknown,
514    }
515}
516
517fn map_order_type(ot: order_type::OrderType) -> &'static str {
518    match ot {
519        order_type::OrderType::Market => "MARKET",
520        order_type::OrderType::Limit => "LIMIT",
521        order_type::OrderType::StopLoss => "STOP_LOSS",
522        order_type::OrderType::StopLossLimit => "STOP_LOSS_LIMIT",
523        order_type::OrderType::TakeProfit => "TAKE_PROFIT",
524        order_type::OrderType::TakeProfitLimit => "TAKE_PROFIT_LIMIT",
525        order_type::OrderType::LimitMaker => "LIMIT_MAKER",
526        _ => "UNKNOWN",
527    }
528}
529
530fn map_expiry_reason(reason: expiry_reason::ExpiryReason) -> Option<String> {
531    match reason {
532        expiry_reason::ExpiryReason::Rejected => Some("REJECTED".to_string()),
533        expiry_reason::ExpiryReason::ExchangeCanceled => Some("EXCHANGE_CANCELED".to_string()),
534        expiry_reason::ExpiryReason::OcoTrigger => Some("OCO_TRIGGER".to_string()),
535        expiry_reason::ExpiryReason::OtoPhaseOneExpired => {
536            Some("OTO_PHASE_ONE_EXPIRED".to_string())
537        }
538        expiry_reason::ExpiryReason::UnfilledIocQuantityExpired => {
539            Some("UNFILLED_IOC_QUANTITY_EXPIRED".to_string())
540        }
541        expiry_reason::ExpiryReason::UnfilledFokOrderExpired => {
542            Some("UNFILLED_FOK_ORDER_EXPIRED".to_string())
543        }
544        expiry_reason::ExpiryReason::InsufficientLiquidity => {
545            Some("INSUFFICIENT_LIQUIDITY".to_string())
546        }
547        expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded => {
548            Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED".to_string())
549        }
550        expiry_reason::ExpiryReason::NonRepresentable => Some("NON_REPRESENTABLE".to_string()),
551        expiry_reason::ExpiryReason::NullVal => None,
552    }
553}
554
555/// Converts an SBE microsecond timestamp to a JSON millisecond timestamp.
556#[inline]
557fn us_to_ms(us: i64) -> i64 {
558    if us < 0 { us } else { us / 1_000 }
559}
560
561/// Converts an SBE `mantissa * 10^exponent` pair to a [`Decimal`] without floating-point.
562fn mantissa_to_decimal(mantissa: i64, exponent: i8) -> Decimal {
563    if exponent >= 0 {
564        Decimal::from(mantissa) * Decimal::from(10_i64.pow(exponent as u32))
565    } else {
566        Decimal::new(mantissa, (-exponent) as u32)
567    }
568}
569
570/// Converts a mantissa + exponent pair to a decimal string without floating-point.
571///
572/// SBE encodes numeric values as `mantissa * 10^exponent`. For exponent = -2
573/// and mantissa = 250000, the result is "2500.00".
574fn mantissa_to_decimal_string(mantissa: i64, exponent: i8) -> String {
575    if mantissa == 0 {
576        if exponent >= 0 {
577            return "0".to_string();
578        }
579        let mut s = "0.".to_string();
580        for _ in 0..(-exponent) {
581            s.push('0');
582        }
583        return s;
584    }
585
586    let negative = mantissa < 0;
587    let abs_mantissa = mantissa.unsigned_abs();
588    let digits = abs_mantissa.to_string();
589
590    let result = if exponent >= 0 {
591        let mut s = digits;
592        for _ in 0..exponent {
593            s.push('0');
594        }
595        s
596    } else {
597        let decimal_places = (-exponent) as usize;
598        if digits.len() <= decimal_places {
599            let padding = decimal_places - digits.len();
600            let mut s = "0.".to_string();
601            for _ in 0..padding {
602                s.push('0');
603            }
604            s.push_str(&digits);
605            s
606        } else {
607            let split_pos = digits.len() - decimal_places;
608            let mut s = digits[..split_pos].to_string();
609            s.push('.');
610            s.push_str(&digits[split_pos..]);
611            s
612        }
613    };
614
615    if negative {
616        format!("-{result}")
617    } else {
618        result
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use rstest::rstest;
625
626    use super::*;
627    use crate::spot::sbe::spot::{
628        WriteBuf, bool_enum::BoolEnum, execution_type::ExecutionType, floor, match_type,
629        order_capacity, order_side::OrderSide, order_status::OrderStatus,
630        order_type::OrderType as SbeOrderType, peg_offset_type, peg_price_type,
631        self_trade_prevention_mode::SelfTradePreventionMode, time_in_force::TimeInForce as SbeTif,
632    };
633
634    #[expect(clippy::too_many_arguments)]
635    fn encode_execution_report(
636        symbol: &str,
637        client_order_id: &str,
638        order_id: i64,
639        trade_id: Option<i64>,
640        side: OrderSide,
641        order_type: SbeOrderType,
642        tif: SbeTif,
643        exec_type: ExecutionType,
644        status: OrderStatus,
645        price_exp: i8,
646        qty_exp: i8,
647        commission_exp: i8,
648        price_mantissa: i64,
649        orig_qty_mantissa: i64,
650        stop_price_mantissa: i64,
651        last_qty_mantissa: i64,
652        last_price_mantissa: i64,
653        executed_qty_mantissa: i64,
654        cumm_quote_qty_mantissa: i64,
655        commission_mantissa: i64,
656        commission_asset: &str,
657        is_maker: bool,
658        is_working: bool,
659        event_time_us: i64,
660        transact_time_us: i64,
661        order_creation_time_us: Option<i64>,
662        expiry_reason: expiry_reason::ExpiryReason,
663    ) -> Vec<u8> {
664        let var_data_len = 6 + symbol.len() + client_order_id.len() + commission_asset.len();
665        let total = 8 + execution_report_event_codec::SBE_BLOCK_LENGTH as usize + var_data_len;
666        let mut buf_vec = vec![0u8; total];
667
668        let buf = WriteBuf::new(buf_vec.as_mut_slice());
669        let enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
670            .wrap(buf, HEADER_LEN);
671        let mut header = enc.header(0);
672        let mut enc = header.parent().unwrap();
673
674        enc.event_time(event_time_us);
675        enc.transact_time(transact_time_us);
676        enc.price_exponent(price_exp);
677        enc.qty_exponent(qty_exp);
678        enc.commission_exponent(commission_exp);
679        enc.order_creation_time(order_creation_time_us.unwrap_or(i64::MIN));
680        enc.working_time(i64::MIN); // null
681        enc.order_id(order_id);
682        enc.order_list_id(i64::MIN); // null
683        enc.orig_qty(orig_qty_mantissa);
684        enc.price(price_mantissa);
685        enc.orig_quote_order_qty(0);
686        enc.iceberg_qty(0);
687        enc.stop_price(stop_price_mantissa);
688        enc.order_type(order_type);
689        enc.side(side);
690        enc.time_in_force(tif);
691        enc.execution_type(exec_type);
692        enc.order_status(status);
693        enc.trade_id(trade_id.unwrap_or(i64::MIN));
694        enc.execution_id(0);
695        enc.executed_qty(executed_qty_mantissa);
696        enc.cummulative_quote_qty(cumm_quote_qty_mantissa);
697        enc.last_qty(last_qty_mantissa);
698        enc.last_price(last_price_mantissa);
699        enc.quote_qty(0);
700        enc.commission(commission_mantissa);
701        enc.is_working(if is_working {
702            BoolEnum::True
703        } else {
704            BoolEnum::False
705        });
706        enc.is_maker(if is_maker {
707            BoolEnum::True
708        } else {
709            BoolEnum::False
710        });
711        enc.is_best_match(BoolEnum::False);
712        enc.match_type(match_type::MatchType::default());
713        enc.self_trade_prevention_mode(SelfTradePreventionMode::default());
714        enc.order_capacity(order_capacity::OrderCapacity::default());
715        enc.working_floor(floor::Floor::default());
716        enc.used_sor(BoolEnum::False);
717        enc.alloc_id(i64::MIN);
718        enc.trailing_delta(u64::MAX);
719        enc.trailing_time(i64::MIN);
720        enc.trade_group_id(i64::MIN);
721        enc.prevented_qty(0);
722        enc.last_prevented_qty(i64::MIN);
723        enc.prevented_match_id(i64::MIN);
724        enc.prevented_execution_qty(i64::MIN);
725        enc.prevented_execution_price(i64::MIN);
726        enc.prevented_execution_quote_qty(i64::MIN);
727        enc.strategy_type(i32::MIN);
728        enc.strategy_id(i64::MIN);
729        enc.counter_order_id(i64::MIN);
730        enc.subscription_id(0xFFFF); // null
731        enc.peg_price_type(peg_price_type::PegPriceType::default());
732        enc.peg_offset_type(peg_offset_type::PegOffsetType::default());
733        enc.peg_offset_value(0xFF); // null
734        enc.pegged_price(i64::MIN);
735        enc.expiry_reason(expiry_reason);
736
737        // Variable-length fields in order
738        enc.symbol(symbol);
739        enc.client_order_id(client_order_id);
740        enc.orig_client_order_id("");
741        enc.commission_asset(commission_asset);
742        enc.reject_reason("");
743        enc.counter_symbol("");
744
745        buf_vec
746    }
747
748    fn encode_bounds_execution_report() -> Vec<u8> {
749        encode_execution_report(
750            "SYMBOL1",
751            "CLIENT2",
752            12345678,
753            Some(87654321),
754            OrderSide::Buy,
755            SbeOrderType::Limit,
756            SbeTif::Gtc,
757            ExecutionType::Trade,
758            OrderStatus::Filled,
759            -2,
760            -5,
761            -8,
762            123456,
763            234567,
764            345678,
765            456789,
766            567891,
767            678912,
768            789123,
769            891234,
770            "ASSET3",
771            true,
772            false,
773            1709654400123456,
774            1709654400234567,
775            Some(1709654400345678),
776            expiry_reason::ExpiryReason::NullVal,
777        )
778    }
779
780    fn encode_account_position(
781        event_time_us: i64,
782        update_time_us: i64,
783        balances: &[(&str, i8, i64, i64)], // (asset, exponent, free, locked)
784    ) -> Vec<u8> {
785        let var_data_len: usize = balances.iter().map(|(a, _, _, _)| 1 + a.len()).sum();
786        let total = 8 + 18 + 6 + (balances.len() * 17) + var_data_len;
787        let mut buf_vec = vec![0u8; total];
788
789        let buf = WriteBuf::new(buf_vec.as_mut_slice());
790        let enc =
791            outbound_account_position_event_codec::OutboundAccountPositionEventEncoder::default()
792                .wrap(buf, HEADER_LEN);
793        let mut header = enc.header(0);
794        let mut enc = header.parent().unwrap();
795
796        enc.event_time(event_time_us);
797        enc.update_time(update_time_us);
798        enc.subscription_id(0xFFFF); // null
799
800        let balances_enc =
801            outbound_account_position_event_codec::encoder::BalancesEncoder::default();
802        let mut bal_enc = enc.balances_encoder(balances.len() as u32, balances_enc);
803
804        for (asset, exponent, free, locked) in balances {
805            bal_enc.advance().unwrap();
806            bal_enc.exponent(*exponent);
807            bal_enc.free(*free);
808            bal_enc.locked(*locked);
809            bal_enc.asset(asset);
810        }
811
812        buf_vec
813    }
814
815    #[rstest]
816    fn test_mantissa_to_decimal_string_basic() {
817        assert_eq!(mantissa_to_decimal_string(250000, -2), "2500.00");
818        assert_eq!(mantissa_to_decimal_string(100000000, -8), "1.00000000");
819        assert_eq!(mantissa_to_decimal_string(0, -8), "0.00000000");
820        assert_eq!(mantissa_to_decimal_string(0, 0), "0");
821        assert_eq!(mantissa_to_decimal_string(42, 0), "42");
822        assert_eq!(mantissa_to_decimal_string(42, 2), "4200");
823        assert_eq!(mantissa_to_decimal_string(5, -3), "0.005");
824        assert_eq!(mantissa_to_decimal_string(-250000, -2), "-2500.00");
825    }
826
827    #[rstest]
828    #[case::typical_price(250000_i64, -2_i8, "2500.00")]
829    #[case::btc_one(100000000_i64, -8_i8, "1.00000000")]
830    #[case::zero_negative_exp(0_i64, -8_i8, "0")]
831    #[case::zero_zero_exp(0_i64, 0_i8, "0")]
832    #[case::whole_no_scale(42_i64, 0_i8, "42")]
833    #[case::positive_exponent(42_i64, 2_i8, "4200")]
834    #[case::small_fractional(5_i64, -3_i8, "0.005")]
835    #[case::negative_mantissa(-250000_i64, -2_i8, "-2500.00")]
836    #[case::large_positive_exponent(1_i64, 9_i8, "1000000000")]
837    fn test_mantissa_to_decimal_parametrized(
838        #[case] mantissa: i64,
839        #[case] exponent: i8,
840        #[case] expected: &str,
841    ) {
842        let result = mantissa_to_decimal(mantissa, exponent);
843        assert_eq!(result, Decimal::from_str_exact(expected).unwrap());
844    }
845
846    #[rstest]
847    #[case::rejected(expiry_reason::ExpiryReason::Rejected, Some("REJECTED"))]
848    #[case::exchange_canceled(
849        expiry_reason::ExpiryReason::ExchangeCanceled,
850        Some("EXCHANGE_CANCELED")
851    )]
852    #[case::oco_trigger(expiry_reason::ExpiryReason::OcoTrigger, Some("OCO_TRIGGER"))]
853    #[case::oto_phase_one_expired(
854        expiry_reason::ExpiryReason::OtoPhaseOneExpired,
855        Some("OTO_PHASE_ONE_EXPIRED")
856    )]
857    #[case::unfilled_ioc_quantity_expired(
858        expiry_reason::ExpiryReason::UnfilledIocQuantityExpired,
859        Some("UNFILLED_IOC_QUANTITY_EXPIRED")
860    )]
861    #[case::unfilled_fok_order_expired(
862        expiry_reason::ExpiryReason::UnfilledFokOrderExpired,
863        Some("UNFILLED_FOK_ORDER_EXPIRED")
864    )]
865    #[case::insufficient_liquidity(
866        expiry_reason::ExpiryReason::InsufficientLiquidity,
867        Some("INSUFFICIENT_LIQUIDITY")
868    )]
869    #[case::execution_rule_price_range_exceeded(
870        expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded,
871        Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED")
872    )]
873    #[case::non_representable(
874        expiry_reason::ExpiryReason::NonRepresentable,
875        Some("NON_REPRESENTABLE")
876    )]
877    #[case::null_val(expiry_reason::ExpiryReason::NullVal, None)]
878    fn test_map_expiry_reason(
879        #[case] reason: expiry_reason::ExpiryReason,
880        #[case] expected: Option<&str>,
881    ) {
882        let result = map_expiry_reason(reason);
883
884        assert_eq!(result.as_deref(), expected);
885    }
886
887    #[rstest]
888    fn test_decode_execution_report_new_limit() {
889        let data = encode_execution_report(
890            "ETHUSDT",
891            "O-20200101-000000-000-000-0",
892            12345678,
893            None, // no trade
894            OrderSide::Buy,
895            SbeOrderType::Limit,
896            SbeTif::Gtc,
897            ExecutionType::New,
898            OrderStatus::New,
899            -2,     // price_exp
900            -5,     // qty_exp
901            -8,     // commission_exp
902            250000, // price = 2500.00
903            100000, // orig_qty = 1.00000
904            0,      // stop_price
905            0,      // last_qty
906            0,      // last_price
907            0,      // executed_qty
908            0,      // cumm_quote_qty
909            0,      // commission
910            "",     // no commission asset yet
911            false,
912            true,             // is_working
913            1709654400000000, // event_time_us
914            1709654400000000, // transact_time_us
915            Some(1709654400000000),
916            expiry_reason::ExpiryReason::NullVal,
917        );
918
919        let report = decode_execution_report(&data).unwrap();
920
921        assert_eq!(report.symbol, "ETHUSDT");
922        assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
923        assert_eq!(report.order_id, 12345678);
924        assert_eq!(report.side, BinanceSide::Buy);
925        assert_eq!(report.order_type, "LIMIT");
926        assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
927        assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
928        assert_eq!(report.order_status, BinanceOrderStatus::New);
929        assert_eq!(report.price, "2500.00");
930        assert_eq!(report.original_qty, "1.00000");
931        assert_eq!(report.trade_id, -1);
932        assert!(report.is_working);
933        assert!(!report.is_maker);
934        assert_eq!(report.event_time, 1709654400000);
935        assert_eq!(report.transaction_time, 1709654400000);
936        assert!(report.expiry_reason.is_none());
937    }
938
939    #[rstest]
940    fn test_decode_execution_report_expiry_reason() {
941        let data = encode_execution_report(
942            "ETHUSDT",
943            "O-20200101-000000-000-000-0",
944            12345678,
945            None,
946            OrderSide::Buy,
947            SbeOrderType::Limit,
948            SbeTif::Ioc,
949            ExecutionType::Expired,
950            OrderStatus::Expired,
951            -2,
952            -5,
953            -8,
954            250000,
955            100000,
956            0,
957            0,
958            0,
959            0,
960            0,
961            0,
962            "",
963            false,
964            false,
965            1709654400000000,
966            1709654400000000,
967            Some(1709654400000000),
968            expiry_reason::ExpiryReason::InsufficientLiquidity,
969        );
970
971        let report = decode_execution_report(&data).unwrap();
972
973        assert_eq!(report.execution_type, BinanceSpotExecutionType::Expired);
974        assert_eq!(report.order_status, BinanceOrderStatus::Expired);
975        assert_eq!(
976            report.expiry_reason.as_deref(),
977            Some("INSUFFICIENT_LIQUIDITY")
978        );
979    }
980
981    #[rstest]
982    fn test_decode_execution_report_trade_fill() {
983        let data = encode_execution_report(
984            "ETHUSDT",
985            "O-20200101-000000-000-000-0",
986            12345678,
987            Some(98765432),
988            OrderSide::Buy,
989            SbeOrderType::Limit,
990            SbeTif::Gtc,
991            ExecutionType::Trade,
992            OrderStatus::Filled,
993            -2,           // price_exp
994            -8,           // qty_exp
995            -8,           // commission_exp
996            250000,       // price = 2500.00
997            100000000,    // orig_qty = 1.00000000
998            0,            // stop_price
999            100000000,    // last_qty = 1.00000000
1000            250000,       // last_price = 2500.00 (uses price_exp)
1001            100000000,    // executed_qty = 1.00000000
1002            250000000000, // cumm_quote_qty = 2500.00000000 (uses price_exp... actually this is wrong)
1003            250000,       // commission = 0.00250000
1004            "USDT",
1005            true, // is_maker
1006            false,
1007            1709654400000000,
1008            1709654400000000,
1009            Some(1709654400000000),
1010            expiry_reason::ExpiryReason::NullVal,
1011        );
1012
1013        let report = decode_execution_report(&data).unwrap();
1014
1015        assert_eq!(report.execution_type, BinanceSpotExecutionType::Trade);
1016        assert_eq!(report.order_status, BinanceOrderStatus::Filled);
1017        assert_eq!(report.trade_id, 98765432);
1018        assert_eq!(report.last_filled_qty, "1.00000000");
1019        assert_eq!(report.last_filled_price, "2500.00");
1020        assert_eq!(report.commission_asset, Some(Ustr::from("USDT")));
1021        assert!(report.is_maker);
1022    }
1023
1024    #[rstest]
1025    fn test_decode_execution_report_canceled() {
1026        let data = encode_execution_report(
1027            "BTCUSDT",
1028            "O-20200101-000000-000-000-1",
1029            99999,
1030            None,
1031            OrderSide::Sell,
1032            SbeOrderType::Limit,
1033            SbeTif::Gtc,
1034            ExecutionType::Canceled,
1035            OrderStatus::Canceled,
1036            -2,
1037            -8,
1038            -8,
1039            5000000,  // price = 50000.00
1040            10000000, // orig_qty = 0.10000000
1041            0,
1042            0,
1043            0,
1044            0,
1045            0,
1046            0,
1047            "",
1048            false,
1049            false,
1050            1709654400000000,
1051            1709654400000000,
1052            Some(1709654400000000),
1053            expiry_reason::ExpiryReason::NullVal,
1054        );
1055
1056        let report = decode_execution_report(&data).unwrap();
1057
1058        assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1059        assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1060        assert_eq!(report.symbol, "BTCUSDT");
1061        assert_eq!(report.side, BinanceSide::Sell);
1062    }
1063
1064    #[rstest]
1065    fn test_decode_execution_report_stop_loss_limit() {
1066        let data = encode_execution_report(
1067            "ETHUSDT",
1068            "O-20200101-000000-000-000-1",
1069            12345679,
1070            None,
1071            OrderSide::Sell,
1072            SbeOrderType::StopLossLimit,
1073            SbeTif::Gtc,
1074            ExecutionType::New,
1075            OrderStatus::New,
1076            -2,
1077            -5,
1078            -8,
1079            240000, // price = 2400.00
1080            100000, // orig_qty = 1.00000
1081            245000, // stop_price = 2450.00
1082            0,
1083            0,
1084            0,
1085            0,
1086            0,
1087            "",
1088            false,
1089            true,
1090            1709654400000000,
1091            1709654400000000,
1092            Some(1709654400000000),
1093            expiry_reason::ExpiryReason::NullVal,
1094        );
1095
1096        let report = decode_execution_report(&data).unwrap();
1097
1098        assert_eq!(report.order_type, "STOP_LOSS_LIMIT");
1099        assert_eq!(report.price, "2400.00");
1100        assert_eq!(report.stop_price, "2450.00");
1101    }
1102
1103    #[rstest]
1104    fn test_decode_execution_report_truncated_header() {
1105        let data = vec![0u8; 5];
1106        let err = decode_execution_report(&data).unwrap_err();
1107        assert!(err.to_string().contains("too short for SBE header"));
1108    }
1109
1110    #[rstest]
1111    fn test_decode_execution_report_rejects_truncated_variable_data() {
1112        let data = encode_bounds_execution_report();
1113        let lengths = ["SYMBOL1".len(), "CLIENT2".len(), 0, "ASSET3".len(), 0, 0];
1114        let mut offset = HEADER_LEN + usize::from(execution_report_event_codec::SBE_BLOCK_LENGTH);
1115
1116        for (field, length) in EXECUTION_REPORT_VAR_DATA_FIELDS.into_iter().zip(lengths) {
1117            let err = decode_execution_report(&data[..offset]).unwrap_err();
1118            assert_eq!(
1119                err.to_string(),
1120                format!(
1121                    "Buffer too short for {field} length: expected {}, was {offset}",
1122                    offset + 1
1123                )
1124            );
1125
1126            if length > 0 {
1127                let truncated_len = offset + length;
1128                let expected_len = offset + 1 + length;
1129                let err = decode_execution_report(&data[..truncated_len]).unwrap_err();
1130                assert_eq!(
1131                    err.to_string(),
1132                    format!(
1133                        "Buffer too short for {field}: expected {expected_len}, was {truncated_len}"
1134                    )
1135                );
1136            }
1137
1138            offset += 1 + length;
1139        }
1140    }
1141
1142    #[rstest]
1143    fn test_decode_execution_report_rejects_short_declared_block() {
1144        let mut data = encode_bounds_execution_report();
1145        let block_length = execution_report_event_codec::SBE_BLOCK_LENGTH - 1;
1146        data[..2].copy_from_slice(&block_length.to_le_bytes());
1147
1148        let err = decode_execution_report(&data).unwrap_err();
1149
1150        assert_eq!(
1151            err.to_string(),
1152            format!(
1153                "SBE execution report block length too short: expected at least {EXECUTION_REPORT_BLOCK_LENGTH_V3}, was {block_length}"
1154            )
1155        );
1156    }
1157
1158    #[rstest]
1159    fn test_decode_execution_report_rejects_short_version_2_block() {
1160        let mut data = crate::common::testing::load_fixture_bytes(
1161            "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1162        );
1163        let block_length = EXECUTION_REPORT_BLOCK_LENGTH_V1 as u16 - 1;
1164        data[..2].copy_from_slice(&block_length.to_le_bytes());
1165
1166        let err = decode_execution_report(&data).unwrap_err();
1167
1168        assert_eq!(
1169            err.to_string(),
1170            format!(
1171                "SBE execution report block length too short: expected at least {EXECUTION_REPORT_BLOCK_LENGTH_V1}, was {block_length}"
1172            )
1173        );
1174    }
1175
1176    #[rstest]
1177    fn test_decode_execution_report_wrong_template() {
1178        let mut data = encode_execution_report(
1179            "TEST",
1180            "test",
1181            1,
1182            None,
1183            OrderSide::Buy,
1184            SbeOrderType::Limit,
1185            SbeTif::Gtc,
1186            ExecutionType::New,
1187            OrderStatus::New,
1188            -2,
1189            -8,
1190            -8,
1191            0,
1192            0,
1193            0,
1194            0,
1195            0,
1196            0,
1197            0,
1198            0,
1199            "",
1200            false,
1201            false,
1202            0,
1203            0,
1204            None,
1205            expiry_reason::ExpiryReason::NullVal,
1206        );
1207        // Overwrite template_id to 50
1208        data[2..4].copy_from_slice(&50u16.to_le_bytes());
1209
1210        let err = decode_execution_report(&data).unwrap_err();
1211        assert!(err.to_string().contains("Wrong template ID"));
1212    }
1213
1214    #[rstest]
1215    fn test_decode_account_position_single_balance() {
1216        let data = encode_account_position(
1217            1709654400000000,                            // event_time_us
1218            1709654400000000,                            // update_time_us
1219            &[("USDT", -8, 1000000000000, 50000000000)], // free=10000.00000000, locked=500.00000000
1220        );
1221
1222        let msg = decode_account_position(&data).unwrap();
1223
1224        assert_eq!(msg.event_type, "outboundAccountPosition");
1225        assert_eq!(msg.event_time, 1709654400000);
1226        assert_eq!(msg.balances.len(), 1);
1227        assert_eq!(msg.balances[0].asset, "USDT");
1228        assert_eq!(
1229            msg.balances[0].free,
1230            Decimal::from_str_exact("10000.00000000").unwrap()
1231        );
1232        assert_eq!(
1233            msg.balances[0].locked,
1234            Decimal::from_str_exact("500.00000000").unwrap()
1235        );
1236    }
1237
1238    #[rstest]
1239    fn test_decode_account_position_multiple_balances() {
1240        let data = encode_account_position(
1241            1709654400000000,
1242            1709654400000000,
1243            &[
1244                ("BTC", -8, 100000000, 0),      // free=1.00000000, locked=0.00000000
1245                ("USDT", -8, 5000000000000, 0), // free=50000.00000000, locked=0.00000000
1246            ],
1247        );
1248
1249        let msg = decode_account_position(&data).unwrap();
1250
1251        assert_eq!(msg.balances.len(), 2);
1252        assert_eq!(msg.balances[0].asset, "BTC");
1253        assert_eq!(
1254            msg.balances[0].free,
1255            Decimal::from_str_exact("1.00000000").unwrap()
1256        );
1257        assert_eq!(msg.balances[1].asset, "USDT");
1258        assert_eq!(
1259            msg.balances[1].free,
1260            Decimal::from_str_exact("50000.00000000").unwrap()
1261        );
1262    }
1263
1264    #[rstest]
1265    fn test_decode_account_position_zero_balances() {
1266        let data = encode_account_position(1709654400000000, 1709654400000000, &[]);
1267
1268        let msg = decode_account_position(&data).unwrap();
1269        assert!(msg.balances.is_empty());
1270    }
1271
1272    #[rstest]
1273    fn test_decode_account_position_truncated_header() {
1274        let data = vec![0u8; 5];
1275        let err = decode_account_position(&data).unwrap_err();
1276        assert!(err.to_string().contains("too short for SBE header"));
1277    }
1278
1279    #[rstest]
1280    fn test_decode_account_position_wrong_template() {
1281        let mut data = encode_account_position(0, 0, &[]);
1282        data[2..4].copy_from_slice(&50u16.to_le_bytes());
1283
1284        let err = decode_account_position(&data).unwrap_err();
1285        assert!(err.to_string().contains("Wrong template ID"));
1286    }
1287
1288    #[rstest]
1289    #[case::zero_v0(0, 0)]
1290    #[case::short_v0(0, ACCOUNT_POSITION_BLOCK_LENGTH_V0 as u16 - 1)]
1291    #[case::zero_v1(1, 0)]
1292    #[case::v0_width_in_v1(1, ACCOUNT_POSITION_BLOCK_LENGTH_V0 as u16)]
1293    #[case::short_v1(1, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1294    #[case::short_current(5, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1295    #[case::short_future(6, outbound_account_position_event_codec::SBE_BLOCK_LENGTH - 1)]
1296    fn test_decode_account_position_rejects_short_declared_block(
1297        #[case] version: u16,
1298        #[case] block_length: u16,
1299    ) {
1300        let mut data = encode_account_position(123000, 456000, &[("BTC", -2, 12345, 6789)]);
1301        data[..2].copy_from_slice(&block_length.to_le_bytes());
1302        data[6..8].copy_from_slice(&version.to_le_bytes());
1303
1304        let min_block_len = if version == 0 {
1305            ACCOUNT_POSITION_BLOCK_LENGTH_V0
1306        } else {
1307            usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
1308        };
1309
1310        let err = decode_account_position(&data).unwrap_err();
1311
1312        assert_eq!(
1313            err.to_string(),
1314            format!(
1315                "SBE account position block length too short: expected at least {min_block_len}, was {block_length}"
1316            )
1317        );
1318    }
1319
1320    #[rstest]
1321    fn test_decode_account_position_rejects_truncated_framing() {
1322        let data = encode_account_position(
1323            123000,
1324            456000,
1325            &[("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)],
1326        );
1327
1328        for length in 0..data.len() {
1329            assert!(
1330                decode_account_position(&data[..length]).is_err(),
1331                "Accepted truncated account position of length {length}"
1332            );
1333        }
1334    }
1335
1336    #[rstest]
1337    #[case::zero_empty(0, 0)]
1338    #[case::zero_nonempty(0, 1)]
1339    #[case::short_empty(ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH as u16 - 1, 0)]
1340    #[case::short_nonempty(ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH as u16 - 1, 1)]
1341    fn test_decode_account_position_rejects_short_entry_block(
1342        #[case] block_length: u16,
1343        #[case] count: u32,
1344    ) {
1345        let mut data = encode_account_position(123000, 456000, &[("BTC", -2, 12345, 6789)]);
1346        let group_offset =
1347            HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
1348        data[group_offset..group_offset + 2].copy_from_slice(&block_length.to_le_bytes());
1349        data[group_offset + 2..group_offset + group_size_encoding_codec::ENCODED_LENGTH]
1350            .copy_from_slice(&count.to_le_bytes());
1351
1352        let err = decode_account_position(&data).unwrap_err();
1353
1354        assert_eq!(
1355            err.to_string(),
1356            format!(
1357                "SBE balances entry block length too short: expected at least {ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH}, was {block_length}"
1358            )
1359        );
1360    }
1361
1362    #[rstest]
1363    #[case::first(0)]
1364    #[case::second(1)]
1365    fn test_decode_account_position_rejects_oversized_asset(#[case] index: usize) {
1366        let mut data = encode_account_position(
1367            123000,
1368            456000,
1369            &[("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)],
1370        );
1371        let mut asset_offset = HEADER_LEN
1372            + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH)
1373            + group_size_encoding_codec::ENCODED_LENGTH
1374            + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
1375
1376        if index == 1 {
1377            asset_offset += 1 + "BTC".len() + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
1378        }
1379
1380        data[asset_offset] = u8::MAX;
1381
1382        let err = decode_account_position(&data).unwrap_err();
1383
1384        assert_eq!(
1385            err.to_string(),
1386            format!(
1387                "Buffer too short for asset: expected {}, was {}",
1388                asset_offset + 1 + usize::from(u8::MAX),
1389                data.len()
1390            )
1391        );
1392    }
1393
1394    #[rstest]
1395    #[case::v0(
1396        0,
1397        ACCOUNT_POSITION_BLOCK_LENGTH_V0,
1398        ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
1399    )]
1400    #[case::v1(
1401        1,
1402        usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH),
1403        ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
1404    )]
1405    #[case::current(
1406        5,
1407        usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH),
1408        ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH
1409    )]
1410    #[case::fixed_extension(5, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH) + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH)]
1411    #[case::entry_extension(5, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH), ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
1412    #[case::v0_extensions(0, ACCOUNT_POSITION_BLOCK_LENGTH_V0 + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
1413    #[case::future_extensions(6, usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH) + 4, ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH + 4)]
1414    fn test_decode_account_position_accepts_versioned_blocks(
1415        #[case] version: u16,
1416        #[case] block_length: usize,
1417        #[case] entry_block_length: usize,
1418    ) {
1419        let balances = [("BTC", -2, 12345, 6789), ("USDT", -3, 4321, 8765)];
1420        let original = encode_account_position(123000, 456000, &balances);
1421        let group_offset =
1422            HEADER_LEN + usize::from(outbound_account_position_event_codec::SBE_BLOCK_LENGTH);
1423        let mut data = original[..HEADER_LEN + ACCOUNT_POSITION_BLOCK_LENGTH_V0].to_vec();
1424        data.resize(HEADER_LEN + block_length, 0xA5);
1425        data[..2].copy_from_slice(&(block_length as u16).to_le_bytes());
1426        data[6..8].copy_from_slice(&version.to_le_bytes());
1427        data.extend_from_slice(
1428            &original[group_offset..group_offset + group_size_encoding_codec::ENCODED_LENGTH],
1429        );
1430        data[HEADER_LEN + block_length..HEADER_LEN + block_length + 2]
1431            .copy_from_slice(&(entry_block_length as u16).to_le_bytes());
1432        let mut entry_offset = group_offset + group_size_encoding_codec::ENCODED_LENGTH;
1433
1434        for (asset, _, _, _) in balances {
1435            data.extend_from_slice(
1436                &original[entry_offset..entry_offset + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH],
1437            );
1438            data.resize(
1439                data.len() + entry_block_length - ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH,
1440                0xA5,
1441            );
1442            let asset_offset = entry_offset + ACCOUNT_POSITION_BALANCE_BLOCK_LENGTH;
1443            entry_offset = asset_offset + 1 + asset.len();
1444            data.extend_from_slice(&original[asset_offset..entry_offset]);
1445        }
1446
1447        let msg = decode_account_position(&data).unwrap();
1448
1449        assert_eq!(msg.event_type, "outboundAccountPosition");
1450        assert_eq!(msg.event_time, 123);
1451        assert_eq!(msg.last_update_time, 456);
1452        assert_eq!(msg.balances.len(), balances.len());
1453
1454        for (balance, (asset, exponent, free, locked)) in msg.balances.iter().zip(balances) {
1455            assert_eq!(balance.asset, asset);
1456            assert_eq!(balance.free, Decimal::new(free, (-exponent) as u32));
1457            assert_eq!(balance.locked, Decimal::new(locked, (-exponent) as u32));
1458        }
1459    }
1460
1461    fn encode_balance_update(
1462        event_time_us: i64,
1463        clear_time_us: i64,
1464        qty_exponent: i8,
1465        free_qty_delta: i64,
1466        asset: &str,
1467    ) -> Vec<u8> {
1468        let total = 8 + 27 + 1 + asset.len();
1469        let mut buf_vec = vec![0u8; total];
1470
1471        let buf = WriteBuf::new(buf_vec.as_mut_slice());
1472        let enc =
1473            balance_update_event_codec::BalanceUpdateEventEncoder::default().wrap(buf, HEADER_LEN);
1474        let mut header = enc.header(0);
1475        let mut enc = header.parent().unwrap();
1476
1477        enc.event_time(event_time_us);
1478        enc.clear_time(clear_time_us);
1479        enc.qty_exponent(qty_exponent);
1480        enc.free_qty_delta(free_qty_delta);
1481        enc.subscription_id(0xFFFF); // null
1482        enc.asset(asset);
1483
1484        buf_vec
1485    }
1486
1487    #[rstest]
1488    fn test_decode_balance_update() {
1489        let data = encode_balance_update(
1490            1709654400000000, // event_time_us
1491            1709654400000000, // clear_time_us
1492            -8,
1493            10000000000, // delta = 100.00000000
1494            "BTC",
1495        );
1496
1497        let msg = decode_balance_update(&data).unwrap();
1498
1499        assert_eq!(msg.event_type, "balanceUpdate");
1500        assert_eq!(msg.event_time, 1709654400000);
1501        assert_eq!(msg.asset, "BTC");
1502        assert_eq!(msg.delta, "100.00000000");
1503        assert_eq!(msg.clear_time, 1709654400000);
1504    }
1505
1506    #[rstest]
1507    fn test_decode_balance_update_truncated_header() {
1508        let data = vec![0u8; 5];
1509        let err = decode_balance_update(&data).unwrap_err();
1510        assert!(err.to_string().contains("too short for SBE header"));
1511    }
1512
1513    #[rstest]
1514    fn test_decode_balance_update_wrong_template() {
1515        let mut data = encode_balance_update(0, 0, -8, 0, "BTC");
1516        data[2..4].copy_from_slice(&50u16.to_le_bytes());
1517
1518        let err = decode_balance_update(&data).unwrap_err();
1519        assert!(err.to_string().contains("Wrong template ID"));
1520    }
1521
1522    #[rstest]
1523    #[case::zero_v0(0, 0)]
1524    #[case::short_v0(0, BALANCE_UPDATE_BLOCK_LENGTH_V0 as u16 - 1)]
1525    #[case::zero_v1(1, 0)]
1526    #[case::v0_width_in_v1(1, BALANCE_UPDATE_BLOCK_LENGTH_V0 as u16)]
1527    #[case::short_v1(1, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
1528    #[case::short_current(5, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
1529    #[case::short_future(6, balance_update_event_codec::SBE_BLOCK_LENGTH - 1)]
1530    fn test_decode_balance_update_rejects_short_declared_block(
1531        #[case] version: u16,
1532        #[case] block_length: u16,
1533    ) {
1534        let mut data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1535        data[..2].copy_from_slice(&block_length.to_le_bytes());
1536        data[6..8].copy_from_slice(&version.to_le_bytes());
1537
1538        let min_block_len = if version == 0 {
1539            BALANCE_UPDATE_BLOCK_LENGTH_V0
1540        } else {
1541            usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH)
1542        };
1543
1544        let err = decode_balance_update(&data).unwrap_err();
1545
1546        assert_eq!(
1547            err.to_string(),
1548            format!(
1549                "SBE balance update block length too short: expected at least {min_block_len}, was {block_length}"
1550            )
1551        );
1552    }
1553
1554    #[rstest]
1555    fn test_decode_balance_update_rejects_truncated_framing() {
1556        let data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1557
1558        for length in 0..data.len() {
1559            assert!(
1560                decode_balance_update(&data[..length]).is_err(),
1561                "Accepted length {length}"
1562            );
1563        }
1564    }
1565
1566    #[rstest]
1567    fn test_decode_balance_update_rejects_oversized_asset() {
1568        let mut data = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1569        let asset_offset = HEADER_LEN + usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH);
1570        data[asset_offset] = u8::MAX;
1571
1572        let err = decode_balance_update(&data).unwrap_err();
1573
1574        assert_eq!(
1575            err.to_string(),
1576            format!(
1577                "Buffer too short for asset: expected {}, was {}",
1578                asset_offset + 1 + usize::from(u8::MAX),
1579                data.len()
1580            )
1581        );
1582    }
1583
1584    #[rstest]
1585    #[case::v0(0, BALANCE_UPDATE_BLOCK_LENGTH_V0)]
1586    #[case::v1(1, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH))]
1587    #[case::current(5, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH))]
1588    #[case::v0_extension(0, BALANCE_UPDATE_BLOCK_LENGTH_V0 + 4)]
1589    #[case::current_extension(5, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH) + 4)]
1590    #[case::future_extension(6, usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH) + 4)]
1591    fn test_decode_balance_update_accepts_versioned_blocks(
1592        #[case] version: u16,
1593        #[case] block_length: usize,
1594    ) {
1595        let original = encode_balance_update(123000, 456000, -2, 12345, "BTC");
1596        let asset_offset = HEADER_LEN + usize::from(balance_update_event_codec::SBE_BLOCK_LENGTH);
1597        let mut data = original[..HEADER_LEN + BALANCE_UPDATE_BLOCK_LENGTH_V0].to_vec();
1598        data.resize(HEADER_LEN + block_length, 0xA5);
1599        data[..2].copy_from_slice(&(block_length as u16).to_le_bytes());
1600        data[6..8].copy_from_slice(&version.to_le_bytes());
1601        data.extend_from_slice(&original[asset_offset..]);
1602
1603        let msg = decode_balance_update(&data).unwrap();
1604
1605        assert_eq!(msg.event_type, "balanceUpdate");
1606        assert_eq!(msg.event_time, 123);
1607        assert_eq!(msg.clear_time, 456);
1608        assert_eq!(msg.asset, "BTC");
1609        assert_eq!(msg.delta, "123.45");
1610    }
1611
1612    #[rstest]
1613    fn test_us_to_ms() {
1614        assert_eq!(us_to_ms(1_709_654_400_000_000), 1_709_654_400_000);
1615        assert_eq!(us_to_ms(1_709_654_400_123_456), 1_709_654_400_123);
1616    }
1617
1618    #[rstest]
1619    fn test_us_to_ms_preserves_negative_submillisecond_value() {
1620        assert_eq!(us_to_ms(-1), -1);
1621    }
1622
1623    #[rstest]
1624    fn test_decode_captured_execution_report_new() {
1625        let data = crate::common::testing::load_fixture_bytes(
1626            "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1627        );
1628        let report = decode_execution_report(&data).unwrap();
1629
1630        assert_eq!(report.symbol, "BTCUSDT");
1631        assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
1632        assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
1633        assert_eq!(report.order_status, BinanceOrderStatus::New);
1634        assert_eq!(report.side, BinanceSide::Buy);
1635        assert_eq!(report.order_type, "LIMIT");
1636        assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
1637        assert_eq!(report.order_id, 12345678);
1638        assert!(report.is_working);
1639        assert!(!report.is_maker);
1640        assert_eq!(report.trade_id, -1);
1641    }
1642
1643    #[rstest]
1644    fn test_decode_captured_execution_report_canceled() {
1645        let data = crate::common::testing::load_fixture_bytes(
1646            "spot/user_data_sbe/mainnet/execution_report_event_2.sbe",
1647        );
1648        let report = decode_execution_report(&data).unwrap();
1649
1650        assert_eq!(report.symbol, "BTCUSDT");
1651        assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1652        assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1653        assert_eq!(report.order_id, 12345678);
1654        assert!(!report.is_working);
1655    }
1656
1657    #[rstest]
1658    fn test_decode_captured_account_position() {
1659        let data = crate::common::testing::load_fixture_bytes(
1660            "spot/user_data_sbe/mainnet/outbound_account_position_event_1.sbe",
1661        );
1662        let msg = decode_account_position(&data).unwrap();
1663
1664        assert_eq!(msg.event_type, "outboundAccountPosition");
1665        assert_eq!(msg.balances.len(), 3);
1666        assert_eq!(msg.balances[0].asset, "BTC");
1667        assert_eq!(
1668            msg.balances[0].free,
1669            Decimal::from_str_exact("1.00000000").unwrap()
1670        );
1671        assert_eq!(msg.balances[1].asset, "BNB");
1672        assert_eq!(msg.balances[2].asset, "USDT");
1673        assert_eq!(
1674            msg.balances[2].free,
1675            Decimal::from_str_exact("50000.00000000").unwrap()
1676        );
1677    }
1678}