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:4 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, message_header_codec, order_side, order_status, order_type,
35        outbound_account_position_event_codec, time_in_force,
36    },
37};
38
39const HEADER_LEN: usize = message_header_codec::ENCODED_LENGTH;
40
41/// Decodes an SBE ExecutionReportEvent (template 603) into a [`BinanceSpotExecutionReport`].
42///
43/// The input buffer must include the 8-byte SBE message header.
44///
45/// # Errors
46///
47/// Returns error if the buffer is too short, the template ID is wrong,
48/// or the schema ID does not match.
49pub fn decode_execution_report(data: &[u8]) -> anyhow::Result<BinanceSpotExecutionReport> {
50    if data.len() < HEADER_LEN {
51        anyhow::bail!(
52            "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
53            data.len()
54        );
55    }
56
57    let buf = ReadBuf::new(data);
58    let block_length = buf.get_u16_at(0);
59    let template_id = buf.get_u16_at(2);
60    let schema_id = buf.get_u16_at(4);
61    let version = buf.get_u16_at(6);
62
63    if template_id != execution_report_event_codec::SBE_TEMPLATE_ID {
64        anyhow::bail!(
65            "Wrong template ID: expected {}, received {template_id}",
66            execution_report_event_codec::SBE_TEMPLATE_ID
67        );
68    }
69
70    if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
71        anyhow::bail!(
72            "Wrong schema ID: expected {}, received {schema_id}",
73            crate::spot::sbe::spot::SBE_SCHEMA_ID
74        );
75    }
76
77    let min_len = HEADER_LEN + block_length as usize;
78    if data.len() < min_len {
79        anyhow::bail!(
80            "Buffer too short for fixed block: expected {min_len}, was {}",
81            data.len()
82        );
83    }
84
85    let mut dec = execution_report_event_codec::ExecutionReportEventDecoder::default().wrap(
86        buf,
87        HEADER_LEN,
88        block_length,
89        version,
90    );
91
92    let price_exp = dec.price_exponent();
93    let qty_exp = dec.qty_exponent();
94    let commission_exp = dec.commission_exponent();
95
96    let event_time_us = dec.event_time();
97    let transact_time_us = dec.transact_time();
98    let order_creation_time_us = dec.order_creation_time();
99    let order_id = dec.order_id();
100    let trade_id = dec.trade_id().unwrap_or(-1);
101
102    let execution_type = map_execution_type(dec.execution_type())?;
103    let order_status = map_order_status(dec.order_status());
104    let side = map_side(dec.side())?;
105    let time_in_force = map_time_in_force(dec.time_in_force());
106    let order_type_str = map_order_type(dec.order_type());
107    let expiry_reason = map_expiry_reason(dec.expiry_reason());
108    let is_working = dec.is_working() == bool_enum::BoolEnum::True;
109    let is_maker = dec.is_maker() == bool_enum::BoolEnum::True;
110
111    let price_mantissa = dec.price();
112    let orig_qty_mantissa = dec.orig_qty();
113    let stop_price_mantissa = dec.stop_price();
114    let last_qty_mantissa = dec.last_qty();
115    let last_price_mantissa = dec.last_price();
116    let executed_qty_mantissa = dec.executed_qty();
117    let cummulative_quote_qty_mantissa = dec.cummulative_quote_qty();
118    let commission_mantissa = dec.commission();
119
120    // Variable-length fields must be read in schema order.
121    // Each field is converted to an owned String immediately to release the borrow.
122    let symbol = {
123        let coords = dec.symbol_decoder();
124        String::from_utf8_lossy(dec.symbol_slice(coords)).into_owned()
125    };
126
127    let client_order_id = {
128        let coords = dec.client_order_id_decoder();
129        String::from_utf8_lossy(dec.client_order_id_slice(coords)).into_owned()
130    };
131
132    let orig_client_order_id = {
133        let coords = dec.orig_client_order_id_decoder();
134        let s = String::from_utf8_lossy(dec.orig_client_order_id_slice(coords)).into_owned();
135        if s.is_empty() { None } else { Some(s) }
136    };
137
138    let commission_asset = {
139        let coords = dec.commission_asset_decoder();
140        let bytes = dec.commission_asset_slice(coords);
141        if bytes.is_empty() {
142            None
143        } else {
144            Some(Ustr::from(&String::from_utf8_lossy(bytes)))
145        }
146    };
147
148    let reject_reason = {
149        let coords = dec.reject_reason_decoder();
150        String::from_utf8_lossy(dec.reject_reason_slice(coords)).into_owned()
151    };
152
153    // counter_symbol - read to advance cursor, not used in venue struct
154    let _counter_symbol_coords = dec.counter_symbol_decoder();
155
156    Ok(BinanceSpotExecutionReport {
157        event_type: "executionReport".to_string(),
158        event_time: us_to_ms(event_time_us),
159        symbol: Ustr::from(&symbol),
160        client_order_id,
161        side,
162        order_type: order_type_str.to_string(),
163        time_in_force,
164        original_qty: mantissa_to_decimal_string(orig_qty_mantissa, qty_exp),
165        price: mantissa_to_decimal_string(price_mantissa, price_exp),
166        stop_price: mantissa_to_decimal_string(stop_price_mantissa, price_exp),
167        execution_type,
168        order_status,
169        reject_reason,
170        order_id,
171        last_filled_qty: mantissa_to_decimal_string(last_qty_mantissa, qty_exp),
172        cumulative_filled_qty: mantissa_to_decimal_string(executed_qty_mantissa, qty_exp),
173        last_filled_price: mantissa_to_decimal_string(last_price_mantissa, price_exp),
174        commission: mantissa_to_decimal_string(commission_mantissa, commission_exp),
175        commission_asset,
176        transaction_time: us_to_ms(transact_time_us),
177        trade_id,
178        is_working,
179        is_maker,
180        order_creation_time: order_creation_time_us.map_or(0, us_to_ms),
181        cumulative_quote_qty: mantissa_to_decimal_string(
182            cummulative_quote_qty_mantissa,
183            price_exp + qty_exp,
184        ),
185        original_client_order_id: orig_client_order_id,
186        expiry_reason,
187    })
188}
189
190/// Decodes an SBE OutboundAccountPositionEvent (template 607) into a
191/// [`BinanceSpotAccountPositionMsg`].
192///
193/// The input buffer must include the 8-byte SBE message header.
194///
195/// # Errors
196///
197/// Returns error if the buffer is too short, the template ID is wrong,
198/// or the schema ID does not match.
199pub fn decode_account_position(data: &[u8]) -> anyhow::Result<BinanceSpotAccountPositionMsg> {
200    if data.len() < HEADER_LEN {
201        anyhow::bail!(
202            "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
203            data.len()
204        );
205    }
206
207    let buf = ReadBuf::new(data);
208    let block_length = buf.get_u16_at(0);
209    let template_id = buf.get_u16_at(2);
210    let schema_id = buf.get_u16_at(4);
211    let version = buf.get_u16_at(6);
212
213    if template_id != outbound_account_position_event_codec::SBE_TEMPLATE_ID {
214        anyhow::bail!(
215            "Wrong template ID: expected {}, received {template_id}",
216            outbound_account_position_event_codec::SBE_TEMPLATE_ID
217        );
218    }
219
220    if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
221        anyhow::bail!(
222            "Wrong schema ID: expected {}, received {schema_id}",
223            crate::spot::sbe::spot::SBE_SCHEMA_ID
224        );
225    }
226
227    let min_len = HEADER_LEN + block_length as usize;
228    if data.len() < min_len {
229        anyhow::bail!(
230            "Buffer too short for fixed block: expected {min_len}, was {}",
231            data.len()
232        );
233    }
234
235    let dec = outbound_account_position_event_codec::OutboundAccountPositionEventDecoder::default()
236        .wrap(buf, HEADER_LEN, block_length, version);
237
238    let event_time_us = dec.event_time();
239    let update_time_us = dec.update_time();
240
241    let mut balances_dec = dec.balances_decoder();
242    let count = balances_dec.count() as usize;
243    let mut balances = Vec::with_capacity(count);
244
245    while let Some(_idx) = balances_dec
246        .advance()
247        .map_err(|e| anyhow::anyhow!("Failed to advance balances group: {e:?}"))?
248    {
249        let exponent = balances_dec.exponent();
250        let free_mantissa = balances_dec.free();
251        let locked_mantissa = balances_dec.locked();
252
253        let asset_coords = balances_dec.asset_decoder();
254        let asset_bytes = balances_dec.asset_slice(asset_coords);
255        let asset = Ustr::from(&String::from_utf8_lossy(asset_bytes));
256
257        balances.push(BinanceSpotBalanceEntry {
258            asset,
259            free: mantissa_to_decimal(free_mantissa, exponent),
260            locked: mantissa_to_decimal(locked_mantissa, exponent),
261        });
262    }
263
264    Ok(BinanceSpotAccountPositionMsg {
265        event_type: "outboundAccountPosition".to_string(),
266        event_time: us_to_ms(event_time_us),
267        last_update_time: us_to_ms(update_time_us),
268        balances,
269    })
270}
271
272/// Decodes an SBE BalanceUpdateEvent (template 601) into a [`BinanceSpotBalanceUpdateMsg`].
273///
274/// The input buffer must include the 8-byte SBE message header.
275///
276/// # Errors
277///
278/// Returns error if the buffer is too short, the template ID is wrong,
279/// or the schema ID does not match.
280pub fn decode_balance_update(data: &[u8]) -> anyhow::Result<BinanceSpotBalanceUpdateMsg> {
281    if data.len() < HEADER_LEN {
282        anyhow::bail!(
283            "Buffer too short for SBE header: expected {HEADER_LEN}, was {}",
284            data.len()
285        );
286    }
287
288    let buf = ReadBuf::new(data);
289    let block_length = buf.get_u16_at(0);
290    let template_id = buf.get_u16_at(2);
291    let schema_id = buf.get_u16_at(4);
292    let version = buf.get_u16_at(6);
293
294    if template_id != balance_update_event_codec::SBE_TEMPLATE_ID {
295        anyhow::bail!(
296            "Wrong template ID: expected {}, received {template_id}",
297            balance_update_event_codec::SBE_TEMPLATE_ID
298        );
299    }
300
301    if schema_id != crate::spot::sbe::spot::SBE_SCHEMA_ID {
302        anyhow::bail!(
303            "Wrong schema ID: expected {}, received {schema_id}",
304            crate::spot::sbe::spot::SBE_SCHEMA_ID
305        );
306    }
307
308    let min_len = HEADER_LEN + block_length as usize;
309    if data.len() < min_len {
310        anyhow::bail!(
311            "Buffer too short for fixed block: expected {min_len}, was {}",
312            data.len()
313        );
314    }
315
316    let mut dec = balance_update_event_codec::BalanceUpdateEventDecoder::default().wrap(
317        buf,
318        HEADER_LEN,
319        block_length,
320        version,
321    );
322
323    let event_time_us = dec.event_time();
324    let clear_time_us = dec.clear_time().unwrap_or(0);
325    let qty_exponent = dec.qty_exponent();
326    let free_qty_delta = dec.free_qty_delta();
327
328    let asset = {
329        let coords = dec.asset_decoder();
330        String::from_utf8_lossy(dec.asset_slice(coords)).into_owned()
331    };
332
333    Ok(BinanceSpotBalanceUpdateMsg {
334        event_type: "balanceUpdate".to_string(),
335        event_time: us_to_ms(event_time_us),
336        asset: Ustr::from(&asset),
337        delta: mantissa_to_decimal_string(free_qty_delta, qty_exponent),
338        clear_time: us_to_ms(clear_time_us),
339    })
340}
341
342fn map_execution_type(
343    et: execution_type::ExecutionType,
344) -> anyhow::Result<BinanceSpotExecutionType> {
345    match et {
346        execution_type::ExecutionType::New => Ok(BinanceSpotExecutionType::New),
347        execution_type::ExecutionType::Canceled => Ok(BinanceSpotExecutionType::Canceled),
348        execution_type::ExecutionType::Replaced => Ok(BinanceSpotExecutionType::Replaced),
349        execution_type::ExecutionType::Rejected => Ok(BinanceSpotExecutionType::Rejected),
350        execution_type::ExecutionType::Trade => Ok(BinanceSpotExecutionType::Trade),
351        execution_type::ExecutionType::Expired => Ok(BinanceSpotExecutionType::Expired),
352        execution_type::ExecutionType::TradePrevention => {
353            Ok(BinanceSpotExecutionType::TradePrevention)
354        }
355        _ => anyhow::bail!("Unsupported SBE execution type: {et}"),
356    }
357}
358
359fn map_order_status(os: order_status::OrderStatus) -> BinanceOrderStatus {
360    match os {
361        order_status::OrderStatus::New => BinanceOrderStatus::New,
362        order_status::OrderStatus::PartiallyFilled => BinanceOrderStatus::PartiallyFilled,
363        order_status::OrderStatus::Filled => BinanceOrderStatus::Filled,
364        order_status::OrderStatus::Canceled => BinanceOrderStatus::Canceled,
365        order_status::OrderStatus::PendingCancel => BinanceOrderStatus::PendingCancel,
366        order_status::OrderStatus::Rejected => BinanceOrderStatus::Rejected,
367        order_status::OrderStatus::Expired => BinanceOrderStatus::Expired,
368        order_status::OrderStatus::ExpiredInMatch => BinanceOrderStatus::ExpiredInMatch,
369        _ => BinanceOrderStatus::Unknown,
370    }
371}
372
373fn map_side(side: order_side::OrderSide) -> anyhow::Result<BinanceSide> {
374    match side {
375        order_side::OrderSide::Buy => Ok(BinanceSide::Buy),
376        order_side::OrderSide::Sell => Ok(BinanceSide::Sell),
377        _ => anyhow::bail!("Unsupported SBE order side: {side}"),
378    }
379}
380
381fn map_time_in_force(tif: time_in_force::TimeInForce) -> BinanceTimeInForce {
382    match tif {
383        time_in_force::TimeInForce::Gtc => BinanceTimeInForce::Gtc,
384        time_in_force::TimeInForce::Ioc => BinanceTimeInForce::Ioc,
385        time_in_force::TimeInForce::Fok => BinanceTimeInForce::Fok,
386        _ => BinanceTimeInForce::Unknown,
387    }
388}
389
390fn map_order_type(ot: order_type::OrderType) -> &'static str {
391    match ot {
392        order_type::OrderType::Market => "MARKET",
393        order_type::OrderType::Limit => "LIMIT",
394        order_type::OrderType::StopLoss => "STOP_LOSS",
395        order_type::OrderType::StopLossLimit => "STOP_LOSS_LIMIT",
396        order_type::OrderType::TakeProfit => "TAKE_PROFIT",
397        order_type::OrderType::TakeProfitLimit => "TAKE_PROFIT_LIMIT",
398        order_type::OrderType::LimitMaker => "LIMIT_MAKER",
399        _ => "UNKNOWN",
400    }
401}
402
403fn map_expiry_reason(reason: expiry_reason::ExpiryReason) -> Option<String> {
404    match reason {
405        expiry_reason::ExpiryReason::Rejected => Some("REJECTED".to_string()),
406        expiry_reason::ExpiryReason::ExchangeCanceled => Some("EXCHANGE_CANCELED".to_string()),
407        expiry_reason::ExpiryReason::OcoTrigger => Some("OCO_TRIGGER".to_string()),
408        expiry_reason::ExpiryReason::OtoPhaseOneExpired => {
409            Some("OTO_PHASE_ONE_EXPIRED".to_string())
410        }
411        expiry_reason::ExpiryReason::UnfilledIocQuantityExpired => {
412            Some("UNFILLED_IOC_QUANTITY_EXPIRED".to_string())
413        }
414        expiry_reason::ExpiryReason::UnfilledFokOrderExpired => {
415            Some("UNFILLED_FOK_ORDER_EXPIRED".to_string())
416        }
417        expiry_reason::ExpiryReason::InsufficientLiquidity => {
418            Some("INSUFFICIENT_LIQUIDITY".to_string())
419        }
420        expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded => {
421            Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED".to_string())
422        }
423        expiry_reason::ExpiryReason::NonRepresentable => Some("NON_REPRESENTABLE".to_string()),
424        expiry_reason::ExpiryReason::NullVal => None,
425    }
426}
427
428/// Converts SBE microsecond timestamp to JSON millisecond timestamp.
429#[inline]
430fn us_to_ms(us: i64) -> i64 {
431    us / 1000
432}
433
434/// Converts an SBE `mantissa * 10^exponent` pair to a [`Decimal`] without floating-point.
435fn mantissa_to_decimal(mantissa: i64, exponent: i8) -> Decimal {
436    if exponent >= 0 {
437        Decimal::from(mantissa) * Decimal::from(10_i64.pow(exponent as u32))
438    } else {
439        Decimal::new(mantissa, (-exponent) as u32)
440    }
441}
442
443/// Converts a mantissa + exponent pair to a decimal string without floating-point.
444///
445/// SBE encodes numeric values as `mantissa * 10^exponent`. For exponent = -2
446/// and mantissa = 250000, the result is "2500.00".
447fn mantissa_to_decimal_string(mantissa: i64, exponent: i8) -> String {
448    if mantissa == 0 {
449        if exponent >= 0 {
450            return "0".to_string();
451        }
452        let mut s = "0.".to_string();
453        for _ in 0..(-exponent) {
454            s.push('0');
455        }
456        return s;
457    }
458
459    let negative = mantissa < 0;
460    let abs_mantissa = mantissa.unsigned_abs();
461    let digits = abs_mantissa.to_string();
462
463    let result = if exponent >= 0 {
464        let mut s = digits;
465        for _ in 0..exponent {
466            s.push('0');
467        }
468        s
469    } else {
470        let decimal_places = (-exponent) as usize;
471        if digits.len() <= decimal_places {
472            let padding = decimal_places - digits.len();
473            let mut s = "0.".to_string();
474            for _ in 0..padding {
475                s.push('0');
476            }
477            s.push_str(&digits);
478            s
479        } else {
480            let split_pos = digits.len() - decimal_places;
481            let mut s = digits[..split_pos].to_string();
482            s.push('.');
483            s.push_str(&digits[split_pos..]);
484            s
485        }
486    };
487
488    if negative {
489        format!("-{result}")
490    } else {
491        result
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use rstest::rstest;
498
499    use super::*;
500    use crate::spot::sbe::spot::{
501        WriteBuf, bool_enum::BoolEnum, execution_type::ExecutionType, floor, match_type,
502        order_capacity, order_side::OrderSide, order_status::OrderStatus,
503        order_type::OrderType as SbeOrderType, peg_offset_type, peg_price_type,
504        self_trade_prevention_mode::SelfTradePreventionMode, time_in_force::TimeInForce as SbeTif,
505    };
506
507    #[expect(clippy::too_many_arguments)]
508    fn encode_execution_report(
509        symbol: &str,
510        client_order_id: &str,
511        order_id: i64,
512        trade_id: Option<i64>,
513        side: OrderSide,
514        order_type: SbeOrderType,
515        tif: SbeTif,
516        exec_type: ExecutionType,
517        status: OrderStatus,
518        price_exp: i8,
519        qty_exp: i8,
520        commission_exp: i8,
521        price_mantissa: i64,
522        orig_qty_mantissa: i64,
523        stop_price_mantissa: i64,
524        last_qty_mantissa: i64,
525        last_price_mantissa: i64,
526        executed_qty_mantissa: i64,
527        cumm_quote_qty_mantissa: i64,
528        commission_mantissa: i64,
529        commission_asset: &str,
530        is_maker: bool,
531        is_working: bool,
532        event_time_us: i64,
533        transact_time_us: i64,
534        order_creation_time_us: Option<i64>,
535        expiry_reason: expiry_reason::ExpiryReason,
536    ) -> Vec<u8> {
537        let var_data_len = 6 + symbol.len() + client_order_id.len() + commission_asset.len();
538        let total = 8 + execution_report_event_codec::SBE_BLOCK_LENGTH as usize + var_data_len;
539        let mut buf_vec = vec![0u8; total];
540
541        let buf = WriteBuf::new(buf_vec.as_mut_slice());
542        let enc = execution_report_event_codec::ExecutionReportEventEncoder::default()
543            .wrap(buf, HEADER_LEN);
544        let mut header = enc.header(0);
545        let mut enc = header.parent().unwrap();
546
547        enc.event_time(event_time_us);
548        enc.transact_time(transact_time_us);
549        enc.price_exponent(price_exp);
550        enc.qty_exponent(qty_exp);
551        enc.commission_exponent(commission_exp);
552        enc.order_creation_time(order_creation_time_us.unwrap_or(i64::MIN));
553        enc.working_time(i64::MIN); // null
554        enc.order_id(order_id);
555        enc.order_list_id(i64::MIN); // null
556        enc.orig_qty(orig_qty_mantissa);
557        enc.price(price_mantissa);
558        enc.orig_quote_order_qty(0);
559        enc.iceberg_qty(0);
560        enc.stop_price(stop_price_mantissa);
561        enc.order_type(order_type);
562        enc.side(side);
563        enc.time_in_force(tif);
564        enc.execution_type(exec_type);
565        enc.order_status(status);
566        enc.trade_id(trade_id.unwrap_or(i64::MIN));
567        enc.execution_id(0);
568        enc.executed_qty(executed_qty_mantissa);
569        enc.cummulative_quote_qty(cumm_quote_qty_mantissa);
570        enc.last_qty(last_qty_mantissa);
571        enc.last_price(last_price_mantissa);
572        enc.quote_qty(0);
573        enc.commission(commission_mantissa);
574        enc.is_working(if is_working {
575            BoolEnum::True
576        } else {
577            BoolEnum::False
578        });
579        enc.is_maker(if is_maker {
580            BoolEnum::True
581        } else {
582            BoolEnum::False
583        });
584        enc.is_best_match(BoolEnum::False);
585        enc.match_type(match_type::MatchType::default());
586        enc.self_trade_prevention_mode(SelfTradePreventionMode::default());
587        enc.order_capacity(order_capacity::OrderCapacity::default());
588        enc.working_floor(floor::Floor::default());
589        enc.used_sor(BoolEnum::False);
590        enc.alloc_id(i64::MIN);
591        enc.trailing_delta(u64::MAX);
592        enc.trailing_time(i64::MIN);
593        enc.trade_group_id(i64::MIN);
594        enc.prevented_qty(0);
595        enc.last_prevented_qty(i64::MIN);
596        enc.prevented_match_id(i64::MIN);
597        enc.prevented_execution_qty(i64::MIN);
598        enc.prevented_execution_price(i64::MIN);
599        enc.prevented_execution_quote_qty(i64::MIN);
600        enc.strategy_type(i32::MIN);
601        enc.strategy_id(i64::MIN);
602        enc.counter_order_id(i64::MIN);
603        enc.subscription_id(0xFFFF); // null
604        enc.peg_price_type(peg_price_type::PegPriceType::default());
605        enc.peg_offset_type(peg_offset_type::PegOffsetType::default());
606        enc.peg_offset_value(0xFF); // null
607        enc.pegged_price(i64::MIN);
608        enc.expiry_reason(expiry_reason);
609
610        // Variable-length fields in order
611        enc.symbol(symbol);
612        enc.client_order_id(client_order_id);
613        enc.orig_client_order_id("");
614        enc.commission_asset(commission_asset);
615        enc.reject_reason("");
616        enc.counter_symbol("");
617
618        buf_vec
619    }
620
621    fn encode_account_position(
622        event_time_us: i64,
623        update_time_us: i64,
624        balances: &[(&str, i8, i64, i64)], // (asset, exponent, free, locked)
625    ) -> Vec<u8> {
626        let var_data_len: usize = balances.iter().map(|(a, _, _, _)| 1 + a.len()).sum();
627        let total = 8 + 18 + 6 + (balances.len() * 17) + var_data_len;
628        let mut buf_vec = vec![0u8; total];
629
630        let buf = WriteBuf::new(buf_vec.as_mut_slice());
631        let enc =
632            outbound_account_position_event_codec::OutboundAccountPositionEventEncoder::default()
633                .wrap(buf, HEADER_LEN);
634        let mut header = enc.header(0);
635        let mut enc = header.parent().unwrap();
636
637        enc.event_time(event_time_us);
638        enc.update_time(update_time_us);
639        enc.subscription_id(0xFFFF); // null
640
641        let balances_enc =
642            outbound_account_position_event_codec::encoder::BalancesEncoder::default();
643        let mut bal_enc = enc.balances_encoder(balances.len() as u32, balances_enc);
644
645        for (asset, exponent, free, locked) in balances {
646            bal_enc.advance().unwrap();
647            bal_enc.exponent(*exponent);
648            bal_enc.free(*free);
649            bal_enc.locked(*locked);
650            bal_enc.asset(asset);
651        }
652
653        buf_vec
654    }
655
656    #[rstest]
657    fn test_mantissa_to_decimal_string_basic() {
658        assert_eq!(mantissa_to_decimal_string(250000, -2), "2500.00");
659        assert_eq!(mantissa_to_decimal_string(100000000, -8), "1.00000000");
660        assert_eq!(mantissa_to_decimal_string(0, -8), "0.00000000");
661        assert_eq!(mantissa_to_decimal_string(0, 0), "0");
662        assert_eq!(mantissa_to_decimal_string(42, 0), "42");
663        assert_eq!(mantissa_to_decimal_string(42, 2), "4200");
664        assert_eq!(mantissa_to_decimal_string(5, -3), "0.005");
665        assert_eq!(mantissa_to_decimal_string(-250000, -2), "-2500.00");
666    }
667
668    #[rstest]
669    #[case::typical_price(250000_i64, -2_i8, "2500.00")]
670    #[case::btc_one(100000000_i64, -8_i8, "1.00000000")]
671    #[case::zero_negative_exp(0_i64, -8_i8, "0")]
672    #[case::zero_zero_exp(0_i64, 0_i8, "0")]
673    #[case::whole_no_scale(42_i64, 0_i8, "42")]
674    #[case::positive_exponent(42_i64, 2_i8, "4200")]
675    #[case::small_fractional(5_i64, -3_i8, "0.005")]
676    #[case::negative_mantissa(-250000_i64, -2_i8, "-2500.00")]
677    #[case::large_positive_exponent(1_i64, 9_i8, "1000000000")]
678    fn test_mantissa_to_decimal_parametrized(
679        #[case] mantissa: i64,
680        #[case] exponent: i8,
681        #[case] expected: &str,
682    ) {
683        let result = mantissa_to_decimal(mantissa, exponent);
684        assert_eq!(result, Decimal::from_str_exact(expected).unwrap());
685    }
686
687    #[rstest]
688    #[case::rejected(expiry_reason::ExpiryReason::Rejected, Some("REJECTED"))]
689    #[case::exchange_canceled(
690        expiry_reason::ExpiryReason::ExchangeCanceled,
691        Some("EXCHANGE_CANCELED")
692    )]
693    #[case::oco_trigger(expiry_reason::ExpiryReason::OcoTrigger, Some("OCO_TRIGGER"))]
694    #[case::oto_phase_one_expired(
695        expiry_reason::ExpiryReason::OtoPhaseOneExpired,
696        Some("OTO_PHASE_ONE_EXPIRED")
697    )]
698    #[case::unfilled_ioc_quantity_expired(
699        expiry_reason::ExpiryReason::UnfilledIocQuantityExpired,
700        Some("UNFILLED_IOC_QUANTITY_EXPIRED")
701    )]
702    #[case::unfilled_fok_order_expired(
703        expiry_reason::ExpiryReason::UnfilledFokOrderExpired,
704        Some("UNFILLED_FOK_ORDER_EXPIRED")
705    )]
706    #[case::insufficient_liquidity(
707        expiry_reason::ExpiryReason::InsufficientLiquidity,
708        Some("INSUFFICIENT_LIQUIDITY")
709    )]
710    #[case::execution_rule_price_range_exceeded(
711        expiry_reason::ExpiryReason::ExecutionRulePriceRangeExceeded,
712        Some("EXECUTION_RULE_PRICE_RANGE_EXCEEDED")
713    )]
714    #[case::non_representable(
715        expiry_reason::ExpiryReason::NonRepresentable,
716        Some("NON_REPRESENTABLE")
717    )]
718    #[case::null_val(expiry_reason::ExpiryReason::NullVal, None)]
719    fn test_map_expiry_reason(
720        #[case] reason: expiry_reason::ExpiryReason,
721        #[case] expected: Option<&str>,
722    ) {
723        let result = map_expiry_reason(reason);
724
725        assert_eq!(result.as_deref(), expected);
726    }
727
728    #[rstest]
729    fn test_decode_execution_report_new_limit() {
730        let data = encode_execution_report(
731            "ETHUSDT",
732            "O-20200101-000000-000-000-0",
733            12345678,
734            None, // no trade
735            OrderSide::Buy,
736            SbeOrderType::Limit,
737            SbeTif::Gtc,
738            ExecutionType::New,
739            OrderStatus::New,
740            -2,     // price_exp
741            -5,     // qty_exp
742            -8,     // commission_exp
743            250000, // price = 2500.00
744            100000, // orig_qty = 1.00000
745            0,      // stop_price
746            0,      // last_qty
747            0,      // last_price
748            0,      // executed_qty
749            0,      // cumm_quote_qty
750            0,      // commission
751            "",     // no commission asset yet
752            false,
753            true,             // is_working
754            1709654400000000, // event_time_us
755            1709654400000000, // transact_time_us
756            Some(1709654400000000),
757            expiry_reason::ExpiryReason::NullVal,
758        );
759
760        let report = decode_execution_report(&data).unwrap();
761
762        assert_eq!(report.symbol, "ETHUSDT");
763        assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
764        assert_eq!(report.order_id, 12345678);
765        assert_eq!(report.side, BinanceSide::Buy);
766        assert_eq!(report.order_type, "LIMIT");
767        assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
768        assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
769        assert_eq!(report.order_status, BinanceOrderStatus::New);
770        assert_eq!(report.price, "2500.00");
771        assert_eq!(report.original_qty, "1.00000");
772        assert_eq!(report.trade_id, -1);
773        assert!(report.is_working);
774        assert!(!report.is_maker);
775        assert_eq!(report.event_time, 1709654400000);
776        assert_eq!(report.transaction_time, 1709654400000);
777        assert!(report.expiry_reason.is_none());
778    }
779
780    #[rstest]
781    fn test_decode_execution_report_expiry_reason() {
782        let data = encode_execution_report(
783            "ETHUSDT",
784            "O-20200101-000000-000-000-0",
785            12345678,
786            None,
787            OrderSide::Buy,
788            SbeOrderType::Limit,
789            SbeTif::Ioc,
790            ExecutionType::Expired,
791            OrderStatus::Expired,
792            -2,
793            -5,
794            -8,
795            250000,
796            100000,
797            0,
798            0,
799            0,
800            0,
801            0,
802            0,
803            "",
804            false,
805            false,
806            1709654400000000,
807            1709654400000000,
808            Some(1709654400000000),
809            expiry_reason::ExpiryReason::InsufficientLiquidity,
810        );
811
812        let report = decode_execution_report(&data).unwrap();
813
814        assert_eq!(report.execution_type, BinanceSpotExecutionType::Expired);
815        assert_eq!(report.order_status, BinanceOrderStatus::Expired);
816        assert_eq!(
817            report.expiry_reason.as_deref(),
818            Some("INSUFFICIENT_LIQUIDITY")
819        );
820    }
821
822    #[rstest]
823    fn test_decode_execution_report_trade_fill() {
824        let data = encode_execution_report(
825            "ETHUSDT",
826            "O-20200101-000000-000-000-0",
827            12345678,
828            Some(98765432),
829            OrderSide::Buy,
830            SbeOrderType::Limit,
831            SbeTif::Gtc,
832            ExecutionType::Trade,
833            OrderStatus::Filled,
834            -2,           // price_exp
835            -8,           // qty_exp
836            -8,           // commission_exp
837            250000,       // price = 2500.00
838            100000000,    // orig_qty = 1.00000000
839            0,            // stop_price
840            100000000,    // last_qty = 1.00000000
841            250000,       // last_price = 2500.00 (uses price_exp)
842            100000000,    // executed_qty = 1.00000000
843            250000000000, // cumm_quote_qty = 2500.00000000 (uses price_exp... actually this is wrong)
844            250000,       // commission = 0.00250000
845            "USDT",
846            true, // is_maker
847            false,
848            1709654400000000,
849            1709654400000000,
850            Some(1709654400000000),
851            expiry_reason::ExpiryReason::NullVal,
852        );
853
854        let report = decode_execution_report(&data).unwrap();
855
856        assert_eq!(report.execution_type, BinanceSpotExecutionType::Trade);
857        assert_eq!(report.order_status, BinanceOrderStatus::Filled);
858        assert_eq!(report.trade_id, 98765432);
859        assert_eq!(report.last_filled_qty, "1.00000000");
860        assert_eq!(report.last_filled_price, "2500.00");
861        assert_eq!(report.commission_asset, Some(Ustr::from("USDT")));
862        assert!(report.is_maker);
863    }
864
865    #[rstest]
866    fn test_decode_execution_report_canceled() {
867        let data = encode_execution_report(
868            "BTCUSDT",
869            "O-20200101-000000-000-000-1",
870            99999,
871            None,
872            OrderSide::Sell,
873            SbeOrderType::Limit,
874            SbeTif::Gtc,
875            ExecutionType::Canceled,
876            OrderStatus::Canceled,
877            -2,
878            -8,
879            -8,
880            5000000,  // price = 50000.00
881            10000000, // orig_qty = 0.10000000
882            0,
883            0,
884            0,
885            0,
886            0,
887            0,
888            "",
889            false,
890            false,
891            1709654400000000,
892            1709654400000000,
893            Some(1709654400000000),
894            expiry_reason::ExpiryReason::NullVal,
895        );
896
897        let report = decode_execution_report(&data).unwrap();
898
899        assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
900        assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
901        assert_eq!(report.symbol, "BTCUSDT");
902        assert_eq!(report.side, BinanceSide::Sell);
903    }
904
905    #[rstest]
906    fn test_decode_execution_report_stop_loss_limit() {
907        let data = encode_execution_report(
908            "ETHUSDT",
909            "O-20200101-000000-000-000-1",
910            12345679,
911            None,
912            OrderSide::Sell,
913            SbeOrderType::StopLossLimit,
914            SbeTif::Gtc,
915            ExecutionType::New,
916            OrderStatus::New,
917            -2,
918            -5,
919            -8,
920            240000, // price = 2400.00
921            100000, // orig_qty = 1.00000
922            245000, // stop_price = 2450.00
923            0,
924            0,
925            0,
926            0,
927            0,
928            "",
929            false,
930            true,
931            1709654400000000,
932            1709654400000000,
933            Some(1709654400000000),
934            expiry_reason::ExpiryReason::NullVal,
935        );
936
937        let report = decode_execution_report(&data).unwrap();
938
939        assert_eq!(report.order_type, "STOP_LOSS_LIMIT");
940        assert_eq!(report.price, "2400.00");
941        assert_eq!(report.stop_price, "2450.00");
942    }
943
944    #[rstest]
945    fn test_decode_execution_report_truncated_header() {
946        let data = vec![0u8; 5];
947        let err = decode_execution_report(&data).unwrap_err();
948        assert!(err.to_string().contains("too short for SBE header"));
949    }
950
951    #[rstest]
952    fn test_decode_execution_report_wrong_template() {
953        let mut data = encode_execution_report(
954            "TEST",
955            "test",
956            1,
957            None,
958            OrderSide::Buy,
959            SbeOrderType::Limit,
960            SbeTif::Gtc,
961            ExecutionType::New,
962            OrderStatus::New,
963            -2,
964            -8,
965            -8,
966            0,
967            0,
968            0,
969            0,
970            0,
971            0,
972            0,
973            0,
974            "",
975            false,
976            false,
977            0,
978            0,
979            None,
980            expiry_reason::ExpiryReason::NullVal,
981        );
982        // Overwrite template_id to 50
983        data[2..4].copy_from_slice(&50u16.to_le_bytes());
984
985        let err = decode_execution_report(&data).unwrap_err();
986        assert!(err.to_string().contains("Wrong template ID"));
987    }
988
989    #[rstest]
990    fn test_decode_account_position_single_balance() {
991        let data = encode_account_position(
992            1709654400000000,                            // event_time_us
993            1709654400000000,                            // update_time_us
994            &[("USDT", -8, 1000000000000, 50000000000)], // free=10000.00000000, locked=500.00000000
995        );
996
997        let msg = decode_account_position(&data).unwrap();
998
999        assert_eq!(msg.event_type, "outboundAccountPosition");
1000        assert_eq!(msg.event_time, 1709654400000);
1001        assert_eq!(msg.balances.len(), 1);
1002        assert_eq!(msg.balances[0].asset, "USDT");
1003        assert_eq!(
1004            msg.balances[0].free,
1005            Decimal::from_str_exact("10000.00000000").unwrap()
1006        );
1007        assert_eq!(
1008            msg.balances[0].locked,
1009            Decimal::from_str_exact("500.00000000").unwrap()
1010        );
1011    }
1012
1013    #[rstest]
1014    fn test_decode_account_position_multiple_balances() {
1015        let data = encode_account_position(
1016            1709654400000000,
1017            1709654400000000,
1018            &[
1019                ("BTC", -8, 100000000, 0),      // free=1.00000000, locked=0.00000000
1020                ("USDT", -8, 5000000000000, 0), // free=50000.00000000, locked=0.00000000
1021            ],
1022        );
1023
1024        let msg = decode_account_position(&data).unwrap();
1025
1026        assert_eq!(msg.balances.len(), 2);
1027        assert_eq!(msg.balances[0].asset, "BTC");
1028        assert_eq!(
1029            msg.balances[0].free,
1030            Decimal::from_str_exact("1.00000000").unwrap()
1031        );
1032        assert_eq!(msg.balances[1].asset, "USDT");
1033        assert_eq!(
1034            msg.balances[1].free,
1035            Decimal::from_str_exact("50000.00000000").unwrap()
1036        );
1037    }
1038
1039    #[rstest]
1040    fn test_decode_account_position_zero_balances() {
1041        let data = encode_account_position(1709654400000000, 1709654400000000, &[]);
1042
1043        let msg = decode_account_position(&data).unwrap();
1044        assert!(msg.balances.is_empty());
1045    }
1046
1047    #[rstest]
1048    fn test_decode_account_position_truncated_header() {
1049        let data = vec![0u8; 5];
1050        let err = decode_account_position(&data).unwrap_err();
1051        assert!(err.to_string().contains("too short for SBE header"));
1052    }
1053
1054    #[rstest]
1055    fn test_decode_account_position_wrong_template() {
1056        let mut data = encode_account_position(0, 0, &[]);
1057        data[2..4].copy_from_slice(&50u16.to_le_bytes());
1058
1059        let err = decode_account_position(&data).unwrap_err();
1060        assert!(err.to_string().contains("Wrong template ID"));
1061    }
1062
1063    fn encode_balance_update(
1064        event_time_us: i64,
1065        clear_time_us: i64,
1066        qty_exponent: i8,
1067        free_qty_delta: i64,
1068        asset: &str,
1069    ) -> Vec<u8> {
1070        let total = 8 + 27 + 1 + asset.len();
1071        let mut buf_vec = vec![0u8; total];
1072
1073        let buf = WriteBuf::new(buf_vec.as_mut_slice());
1074        let enc =
1075            balance_update_event_codec::BalanceUpdateEventEncoder::default().wrap(buf, HEADER_LEN);
1076        let mut header = enc.header(0);
1077        let mut enc = header.parent().unwrap();
1078
1079        enc.event_time(event_time_us);
1080        enc.clear_time(clear_time_us);
1081        enc.qty_exponent(qty_exponent);
1082        enc.free_qty_delta(free_qty_delta);
1083        enc.subscription_id(0xFFFF); // null
1084        enc.asset(asset);
1085
1086        buf_vec
1087    }
1088
1089    #[rstest]
1090    fn test_decode_balance_update() {
1091        let data = encode_balance_update(
1092            1709654400000000, // event_time_us
1093            1709654400000000, // clear_time_us
1094            -8,
1095            10000000000, // delta = 100.00000000
1096            "BTC",
1097        );
1098
1099        let msg = decode_balance_update(&data).unwrap();
1100
1101        assert_eq!(msg.event_type, "balanceUpdate");
1102        assert_eq!(msg.event_time, 1709654400000);
1103        assert_eq!(msg.asset, "BTC");
1104        assert_eq!(msg.delta, "100.00000000");
1105        assert_eq!(msg.clear_time, 1709654400000);
1106    }
1107
1108    #[rstest]
1109    fn test_decode_balance_update_truncated_header() {
1110        let data = vec![0u8; 5];
1111        let err = decode_balance_update(&data).unwrap_err();
1112        assert!(err.to_string().contains("too short for SBE header"));
1113    }
1114
1115    #[rstest]
1116    fn test_decode_balance_update_wrong_template() {
1117        let mut data = encode_balance_update(0, 0, -8, 0, "BTC");
1118        data[2..4].copy_from_slice(&50u16.to_le_bytes());
1119
1120        let err = decode_balance_update(&data).unwrap_err();
1121        assert!(err.to_string().contains("Wrong template ID"));
1122    }
1123
1124    #[rstest]
1125    fn test_us_to_ms() {
1126        assert_eq!(us_to_ms(1709654400000000), 1709654400000);
1127        assert_eq!(us_to_ms(1709654400123456), 1709654400123);
1128    }
1129
1130    #[rstest]
1131    fn test_decode_captured_execution_report_new() {
1132        let data = crate::common::testing::load_fixture_bytes(
1133            "spot/user_data_sbe/mainnet/execution_report_event_1.sbe",
1134        );
1135        let report = decode_execution_report(&data).unwrap();
1136
1137        assert_eq!(report.symbol, "BTCUSDT");
1138        assert_eq!(report.client_order_id, "O-20200101-000000-000-000-0");
1139        assert_eq!(report.execution_type, BinanceSpotExecutionType::New);
1140        assert_eq!(report.order_status, BinanceOrderStatus::New);
1141        assert_eq!(report.side, BinanceSide::Buy);
1142        assert_eq!(report.order_type, "LIMIT");
1143        assert_eq!(report.time_in_force, BinanceTimeInForce::Gtc);
1144        assert_eq!(report.order_id, 12345678);
1145        assert!(report.is_working);
1146        assert!(!report.is_maker);
1147        assert_eq!(report.trade_id, -1);
1148    }
1149
1150    #[rstest]
1151    fn test_decode_captured_execution_report_canceled() {
1152        let data = crate::common::testing::load_fixture_bytes(
1153            "spot/user_data_sbe/mainnet/execution_report_event_2.sbe",
1154        );
1155        let report = decode_execution_report(&data).unwrap();
1156
1157        assert_eq!(report.symbol, "BTCUSDT");
1158        assert_eq!(report.execution_type, BinanceSpotExecutionType::Canceled);
1159        assert_eq!(report.order_status, BinanceOrderStatus::Canceled);
1160        assert_eq!(report.order_id, 12345678);
1161        assert!(!report.is_working);
1162    }
1163
1164    #[rstest]
1165    fn test_decode_captured_account_position() {
1166        let data = crate::common::testing::load_fixture_bytes(
1167            "spot/user_data_sbe/mainnet/outbound_account_position_event_1.sbe",
1168        );
1169        let msg = decode_account_position(&data).unwrap();
1170
1171        assert_eq!(msg.event_type, "outboundAccountPosition");
1172        assert_eq!(msg.balances.len(), 3);
1173        assert_eq!(msg.balances[0].asset, "BTC");
1174        assert_eq!(
1175            msg.balances[0].free,
1176            Decimal::from_str_exact("1.00000000").unwrap()
1177        );
1178        assert_eq!(msg.balances[1].asset, "BNB");
1179        assert_eq!(msg.balances[2].asset, "USDT");
1180        assert_eq!(
1181            msg.balances[2].free,
1182            Decimal::from_str_exact("50000.00000000").unwrap()
1183        );
1184    }
1185}