Skip to main content

nautilus_binance/spot/websocket/trading/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parse functions for converting Binance Spot venue types to Nautilus reports.
17//!
18//! Pure functions that take venue message + instrument + account_id + ts_init
19//! and return Nautilus report types.
20
21use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23    enums::{AccountType, LiquiditySide, OrderSide, OrderStatus, OrderType, TimeInForce},
24    events::AccountState,
25    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, VenueOrderId},
26    reports::{FillReport, OrderStatusReport},
27    types::{AccountBalance, Currency, Money, Price},
28};
29use rust_decimal::Decimal;
30
31use super::user_data::{BinanceSpotAccountPositionMsg, BinanceSpotExecutionReport};
32use crate::common::{
33    consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
34    encoder::decode_broker_id,
35    enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
36    parse::{
37        parse_required_decimal, parse_required_price_at_precision,
38        parse_required_quantity_at_precision,
39    },
40};
41
42/// Converts a Binance Spot execution report to a Nautilus order status report.
43///
44/// # Errors
45///
46/// Returns an error if report construction fails.
47pub fn parse_spot_exec_report_to_order_status(
48    msg: &BinanceSpotExecutionReport,
49    instrument_id: InstrumentId,
50    price_precision: u8,
51    size_precision: u8,
52    account_id: AccountId,
53    treat_expired_as_canceled: bool,
54    ts_init: UnixNanos,
55) -> anyhow::Result<OrderStatusReport> {
56    let client_order_id = ClientOrderId::new(decode_broker_id(
57        &msg.client_order_id,
58        BINANCE_NAUTILUS_SPOT_BROKER_ID,
59    ));
60    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
61    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
62
63    let order_side = match msg.side {
64        BinanceSide::Buy => OrderSide::Buy,
65        BinanceSide::Sell => OrderSide::Sell,
66    };
67
68    let order_status = parse_order_status(msg.order_status, treat_expired_as_canceled);
69    let order_type = parse_spot_order_type(&msg.order_type);
70    let time_in_force = parse_time_in_force(msg.time_in_force);
71
72    let quantity =
73        parse_required_quantity_at_precision(&msg.original_qty, size_precision, "original_qty")?;
74    let filled_qty = parse_required_quantity_at_precision(
75        &msg.cumulative_filled_qty,
76        size_precision,
77        "cumulative_filled_qty",
78    )?;
79    let price = parse_required_price_at_precision(&msg.price, price_precision, "price")?;
80
81    let filled_qty_decimal =
82        parse_required_decimal(&msg.cumulative_filled_qty, "cumulative_filled_qty")?;
83    let avg_px = if filled_qty_decimal > Decimal::ZERO {
84        let cum_quote = parse_required_decimal(&msg.cumulative_quote_qty, "cumulative_quote_qty")?;
85        let avg_px = cum_quote.checked_div(filled_qty_decimal).ok_or_else(|| {
86            anyhow::anyhow!(
87                "invalid cumulative_quote_qty='{}' for cumulative_filled_qty='{}': division overflow",
88                msg.cumulative_quote_qty,
89                msg.cumulative_filled_qty,
90            )
91        })?;
92        Some(Price::from_decimal_dp(avg_px, price_precision)?)
93    } else {
94        None
95    };
96
97    let mut report = OrderStatusReport::new(
98        account_id,
99        instrument_id,
100        Some(client_order_id),
101        venue_order_id,
102        order_side,
103        order_type,
104        time_in_force,
105        order_status,
106        quantity,
107        filled_qty,
108        ts_event,
109        ts_event,
110        ts_init,
111        None, // report_id
112    );
113
114    report.price = Some(price);
115    report.post_only = msg.order_type == "LIMIT_MAKER";
116
117    let stop_price = parse_required_decimal(&msg.stop_price, "stop_price")?;
118    if stop_price > Decimal::ZERO {
119        report.trigger_price = Some(parse_required_price_at_precision(
120            &msg.stop_price,
121            price_precision,
122            "stop_price",
123        )?);
124    }
125
126    if let Some(avg) = avg_px {
127        report.avg_px = Some(avg.as_decimal());
128    }
129
130    Ok(report)
131}
132
133/// Converts a Binance Spot execution report (Trade type) to a Nautilus fill report.
134///
135/// # Errors
136///
137/// Returns an error if report construction fails.
138pub fn parse_spot_exec_report_to_fill(
139    msg: &BinanceSpotExecutionReport,
140    instrument_id: InstrumentId,
141    price_precision: u8,
142    size_precision: u8,
143    account_id: AccountId,
144    ts_init: UnixNanos,
145) -> anyhow::Result<FillReport> {
146    let client_order_id = ClientOrderId::new(decode_broker_id(
147        &msg.client_order_id,
148        BINANCE_NAUTILUS_SPOT_BROKER_ID,
149    ));
150    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
151    let trade_id = TradeId::new(msg.trade_id.to_string());
152    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
153
154    let order_side = match msg.side {
155        BinanceSide::Buy => OrderSide::Buy,
156        BinanceSide::Sell => OrderSide::Sell,
157    };
158
159    let liquidity_side = if msg.is_maker {
160        LiquiditySide::Maker
161    } else {
162        LiquiditySide::Taker
163    };
164
165    let last_qty = parse_required_quantity_at_precision(
166        &msg.last_filled_qty,
167        size_precision,
168        "last_filled_qty",
169    )?;
170    let last_px = parse_required_price_at_precision(
171        &msg.last_filled_price,
172        price_precision,
173        "last_filled_price",
174    )?;
175    let commission = parse_required_decimal(&msg.commission, "commission")?;
176    let commission_currency = msg
177        .commission_asset
178        .as_ref()
179        .map_or_else(Currency::USDT, |a| {
180            Currency::get_or_create_crypto(a.as_str())
181        });
182
183    Ok(FillReport::new(
184        account_id,
185        instrument_id,
186        venue_order_id,
187        trade_id,
188        order_side,
189        last_qty,
190        last_px,
191        Money::from_decimal(commission, commission_currency)?,
192        liquidity_side,
193        Some(client_order_id),
194        None, // venue_position_id
195        ts_event,
196        ts_init,
197        None, // report_id
198    ))
199}
200
201/// Converts a Binance Spot account position update to a Nautilus account state.
202pub fn parse_spot_account_position(
203    msg: &BinanceSpotAccountPositionMsg,
204    account_id: AccountId,
205    ts_init: UnixNanos,
206) -> AccountState {
207    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
208
209    let balances: Vec<AccountBalance> = msg
210        .balances
211        .iter()
212        .filter_map(|b| {
213            let total = b.free + b.locked;
214            let currency = Currency::get_or_create_crypto(b.asset.as_str());
215            AccountBalance::from_total_and_locked(total, b.locked, currency).ok()
216        })
217        .collect();
218
219    AccountState::new(
220        account_id,
221        AccountType::Cash,
222        balances,
223        vec![], // No margins for spot
224        true,   // is_reported
225        UUID4::new(),
226        ts_event,
227        ts_init,
228        None, // base_currency
229    )
230}
231
232fn parse_order_status(status: BinanceOrderStatus, treat_expired_as_canceled: bool) -> OrderStatus {
233    match status {
234        BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
235        BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
236        BinanceOrderStatus::Filled
237        | BinanceOrderStatus::NewAdl
238        | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
239        BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
240        BinanceOrderStatus::Rejected => OrderStatus::Rejected,
241        BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => {
242            if treat_expired_as_canceled {
243                OrderStatus::Canceled
244            } else {
245                OrderStatus::Expired
246            }
247        }
248        BinanceOrderStatus::Unknown => OrderStatus::Accepted,
249    }
250}
251
252fn parse_spot_order_type(order_type: &str) -> OrderType {
253    match order_type {
254        "LIMIT" | "LIMIT_MAKER" => OrderType::Limit,
255        "MARKET" => OrderType::Market,
256        "STOP_LOSS" => OrderType::StopMarket,
257        "STOP_LOSS_LIMIT" => OrderType::StopLimit,
258        "TAKE_PROFIT" => OrderType::MarketIfTouched,
259        "TAKE_PROFIT_LIMIT" => OrderType::LimitIfTouched,
260        _ => OrderType::Market,
261    }
262}
263
264fn parse_time_in_force(tif: BinanceTimeInForce) -> TimeInForce {
265    match tif {
266        BinanceTimeInForce::Gtc | BinanceTimeInForce::Gtx => TimeInForce::Gtc,
267        BinanceTimeInForce::Ioc | BinanceTimeInForce::Rpi => TimeInForce::Ioc,
268        BinanceTimeInForce::Fok => TimeInForce::Fok,
269        BinanceTimeInForce::Gtd => TimeInForce::Gtd,
270        BinanceTimeInForce::Unknown => TimeInForce::Gtc,
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use nautilus_model::types::Quantity;
277    use rstest::rstest;
278
279    use super::*;
280    use crate::{
281        common::testing::load_fixture_string,
282        spot::websocket::trading::user_data::BinanceSpotExecutionReport,
283    };
284
285    const PRICE_PRECISION: u8 = 2;
286    const SIZE_PRECISION: u8 = 5;
287
288    fn instrument_id() -> InstrumentId {
289        InstrumentId::from("ETHUSDT.BINANCE")
290    }
291
292    #[rstest]
293    #[case::as_expired(false, OrderStatus::Expired)]
294    #[case::as_canceled(true, OrderStatus::Canceled)]
295    fn test_parse_order_status_expired_respects_treat_as_canceled(
296        #[case] treat_expired_as_canceled: bool,
297        #[case] expected: OrderStatus,
298    ) {
299        assert_eq!(
300            parse_order_status(BinanceOrderStatus::Expired, treat_expired_as_canceled),
301            expected,
302        );
303        assert_eq!(
304            parse_order_status(
305                BinanceOrderStatus::ExpiredInMatch,
306                treat_expired_as_canceled,
307            ),
308            expected,
309        );
310    }
311
312    #[rstest]
313    fn test_parse_execution_report_to_order_status_report() {
314        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
315        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
316        let account_id = AccountId::from("BINANCE-001");
317        let ts_init = UnixNanos::from(1_000_000_000u64);
318
319        let report = parse_spot_exec_report_to_order_status(
320            &msg,
321            instrument_id(),
322            PRICE_PRECISION,
323            SIZE_PRECISION,
324            account_id,
325            false,
326            ts_init,
327        )
328        .unwrap();
329
330        assert_eq!(report.account_id, account_id);
331        assert_eq!(report.instrument_id, instrument_id());
332        assert_eq!(report.order_side, OrderSide::Buy);
333        assert_eq!(report.order_status, OrderStatus::Accepted);
334        assert_eq!(report.order_type, OrderType::Limit);
335        assert_eq!(report.time_in_force, TimeInForce::Gtc);
336        assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
337        assert_eq!(
338            report.client_order_id,
339            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
340        );
341        assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
342        assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
343        assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
344        assert!(report.avg_px.is_none());
345        assert!(!report.post_only);
346        assert!(report.trigger_price.is_none());
347        assert_eq!(
348            report.ts_accepted,
349            UnixNanos::from(1_709_654_400_000_000_000u64)
350        );
351        assert_eq!(
352            report.ts_last,
353            UnixNanos::from(1_709_654_400_000_000_000u64)
354        );
355        assert_eq!(report.ts_init, ts_init);
356    }
357
358    #[rstest]
359    fn test_parse_execution_report_to_order_status_rejects_invalid_quantity() {
360        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
361        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
362        msg.original_qty = "not-a-number".to_string();
363        let account_id = AccountId::from("BINANCE-001");
364        let ts_init = UnixNanos::from(1_000_000_000u64);
365
366        let result = parse_spot_exec_report_to_order_status(
367            &msg,
368            instrument_id(),
369            PRICE_PRECISION,
370            SIZE_PRECISION,
371            account_id,
372            false,
373            ts_init,
374        );
375
376        let error = result.unwrap_err().to_string();
377        assert!(error.contains("original_qty"));
378    }
379
380    #[rstest]
381    fn test_parse_execution_report_limit_maker_sets_post_only() {
382        let json = r#"{
383            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
384            "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
385            "f":"GTC","q":"0.5","p":"2600.00","P":"0",
386            "x":"NEW","X":"NEW","r":"NONE","i":12345679,
387            "l":"0","z":"0","L":"0","n":"0","N":null,
388            "T":1709654400000,"t":-1,"w":true,"m":false,
389            "O":1709654400000,"Z":"0","C":""
390        }"#;
391        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
392        let account_id = AccountId::from("BINANCE-001");
393        let ts_init = UnixNanos::from(1_000_000_000u64);
394
395        let report = parse_spot_exec_report_to_order_status(
396            &msg,
397            instrument_id(),
398            PRICE_PRECISION,
399            SIZE_PRECISION,
400            account_id,
401            false,
402            ts_init,
403        )
404        .unwrap();
405
406        assert_eq!(report.order_type, OrderType::Limit);
407        assert!(report.post_only, "LIMIT_MAKER must set post_only");
408    }
409
410    #[rstest]
411    fn test_parse_execution_report_partial_fill_computes_avg_px() {
412        let json = r#"{
413            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
414            "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
415            "f":"GTC","q":"2.0","p":"2500.00","P":"0",
416            "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
417            "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
418            "T":1709654400000,"t":98765432,"w":true,"m":false,
419            "O":1709654400000,"Z":"1249.50","C":""
420        }"#;
421        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
422        let account_id = AccountId::from("BINANCE-001");
423        let ts_init = UnixNanos::from(1_000_000_000u64);
424
425        let report = parse_spot_exec_report_to_order_status(
426            &msg,
427            instrument_id(),
428            PRICE_PRECISION,
429            SIZE_PRECISION,
430            account_id,
431            false,
432            ts_init,
433        )
434        .unwrap();
435
436        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
437        assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
438        assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
439
440        // avg_px = cum_quote / filled_qty = 1249.50 / 0.5 = 2499.00
441        assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
442    }
443
444    #[rstest]
445    fn test_parse_execution_report_rejects_overflowing_avg_px() {
446        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
447        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
448        msg.cumulative_quote_qty = Decimal::MAX.to_string();
449        msg.cumulative_filled_qty = "0.00000001".to_string();
450        let account_id = AccountId::from("BINANCE-001");
451        let ts_init = UnixNanos::from(1_000_000_000u64);
452
453        let result = parse_spot_exec_report_to_order_status(
454            &msg,
455            instrument_id(),
456            PRICE_PRECISION,
457            SIZE_PRECISION,
458            account_id,
459            false,
460            ts_init,
461        );
462
463        let error = result.unwrap_err().to_string();
464        assert!(error.contains("cumulative_quote_qty"));
465        assert!(error.contains("division overflow"));
466    }
467
468    #[rstest]
469    fn test_parse_execution_report_stop_loss_has_trigger_price() {
470        let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
471        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
472        let account_id = AccountId::from("BINANCE-001");
473        let ts_init = UnixNanos::from(1_000_000_000u64);
474
475        let report = parse_spot_exec_report_to_order_status(
476            &msg,
477            instrument_id(),
478            PRICE_PRECISION,
479            SIZE_PRECISION,
480            account_id,
481            false,
482            ts_init,
483        )
484        .unwrap();
485
486        assert_eq!(report.order_type, OrderType::StopLimit);
487        assert_eq!(report.order_side, OrderSide::Sell);
488        assert_eq!(
489            report.client_order_id,
490            Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
491        );
492        assert_eq!(
493            report.trigger_price,
494            Some(Price::new(2450.0, PRICE_PRECISION))
495        );
496        assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
497    }
498
499    #[rstest]
500    fn test_parse_execution_report_to_fill_report() {
501        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
502        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
503        let account_id = AccountId::from("BINANCE-001");
504        let ts_init = UnixNanos::from(1_000_000_000u64);
505
506        let report = parse_spot_exec_report_to_fill(
507            &msg,
508            instrument_id(),
509            PRICE_PRECISION,
510            SIZE_PRECISION,
511            account_id,
512            ts_init,
513        )
514        .unwrap();
515
516        assert_eq!(report.account_id, account_id);
517        assert_eq!(report.instrument_id, instrument_id());
518        assert_eq!(report.order_side, OrderSide::Buy);
519        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
520        assert_eq!(report.trade_id, TradeId::new("98765432"));
521        assert_eq!(
522            report.client_order_id,
523            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
524        );
525    }
526
527    #[rstest]
528    fn test_parse_execution_report_to_fill_rejects_invalid_commission() {
529        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
530        let mut msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
531        msg.commission = "not-a-number".to_string();
532        let account_id = AccountId::from("BINANCE-001");
533        let ts_init = UnixNanos::from(1_000_000_000u64);
534
535        let result = parse_spot_exec_report_to_fill(
536            &msg,
537            instrument_id(),
538            PRICE_PRECISION,
539            SIZE_PRECISION,
540            account_id,
541            ts_init,
542        );
543
544        let error = result.unwrap_err().to_string();
545        assert!(error.contains("commission"));
546    }
547
548    #[rstest]
549    fn test_parse_account_position() {
550        let json = load_fixture_string("spot/user_data_json/account_position.json");
551        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(&json).unwrap();
552        let account_id = AccountId::from("BINANCE-001");
553        let ts_init = UnixNanos::from(1_000_000_000u64);
554
555        let state = parse_spot_account_position(&msg, account_id, ts_init);
556
557        assert_eq!(state.account_id, account_id);
558        assert_eq!(state.account_type, AccountType::Cash);
559        assert!(state.is_reported);
560        assert_eq!(state.balances.len(), 2);
561    }
562
563    // Regression for the #3867 bug class: WS `free` and `locked` with more decimal places
564    // than the asset's currency precision used to trip the invariant when Money::new rounded
565    // each side independently.
566    #[rstest]
567    fn test_parse_account_position_precision_drift() {
568        let json = r#"{
569            "e": "outboundAccountPosition",
570            "E": 1700000000000,
571            "u": 1700000000000,
572            "B": [{
573                "a": "ETH",
574                "f": "9.999999994999",
575                "l": "0.000000040000"
576            }]
577        }"#;
578        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
579        let account_id = AccountId::from("BINANCE-001");
580        let ts_init = UnixNanos::from(1_000_000_000u64);
581
582        let state = parse_spot_account_position(&msg, account_id, ts_init);
583
584        assert_eq!(state.balances.len(), 1);
585        let balance = &state.balances[0];
586        assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
587    }
588}