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