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, Quantity},
28};
29
30use super::user_data::{BinanceSpotAccountPositionMsg, BinanceSpotExecutionReport};
31use crate::common::{
32    consts::BINANCE_NAUTILUS_SPOT_BROKER_ID,
33    encoder::decode_broker_id,
34    enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
35};
36
37/// Converts a Binance Spot execution report to a Nautilus order status report.
38///
39/// # Errors
40///
41/// Returns an error if report construction fails.
42pub fn parse_spot_exec_report_to_order_status(
43    msg: &BinanceSpotExecutionReport,
44    instrument_id: InstrumentId,
45    price_precision: u8,
46    size_precision: u8,
47    account_id: AccountId,
48    ts_init: UnixNanos,
49) -> anyhow::Result<OrderStatusReport> {
50    let client_order_id = ClientOrderId::new(decode_broker_id(
51        &msg.client_order_id,
52        BINANCE_NAUTILUS_SPOT_BROKER_ID,
53    ));
54    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
55    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
56
57    let order_side = match msg.side {
58        BinanceSide::Buy => OrderSide::Buy,
59        BinanceSide::Sell => OrderSide::Sell,
60    };
61
62    let order_status = parse_order_status(msg.order_status);
63    let order_type = parse_spot_order_type(&msg.order_type);
64    let time_in_force = parse_time_in_force(msg.time_in_force);
65
66    let quantity: f64 = msg.original_qty.parse().unwrap_or(0.0);
67    let filled_qty: f64 = msg.cumulative_filled_qty.parse().unwrap_or(0.0);
68    let price: f64 = msg.price.parse().unwrap_or(0.0);
69
70    let avg_px = if filled_qty > 0.0 {
71        let cum_quote: f64 = msg.cumulative_quote_qty.parse().unwrap_or(0.0);
72        Some(Price::new(cum_quote / filled_qty, price_precision))
73    } else {
74        None
75    };
76
77    let mut report = OrderStatusReport::new(
78        account_id,
79        instrument_id,
80        Some(client_order_id),
81        venue_order_id,
82        order_side,
83        order_type,
84        time_in_force,
85        order_status,
86        Quantity::new(quantity, size_precision),
87        Quantity::new(filled_qty, size_precision),
88        ts_event,
89        ts_event,
90        ts_init,
91        None, // report_id
92    );
93
94    report.price = Some(Price::new(price, price_precision));
95    report.post_only = msg.order_type == "LIMIT_MAKER";
96
97    let stop_price: f64 = msg.stop_price.parse().unwrap_or(0.0);
98    if stop_price > 0.0 {
99        report.trigger_price = Some(Price::new(stop_price, price_precision));
100    }
101
102    if let Some(avg) = avg_px {
103        report.avg_px = Some(avg.as_decimal());
104    }
105
106    Ok(report)
107}
108
109/// Converts a Binance Spot execution report (Trade type) to a Nautilus fill report.
110///
111/// # Errors
112///
113/// Returns an error if report construction fails.
114pub fn parse_spot_exec_report_to_fill(
115    msg: &BinanceSpotExecutionReport,
116    instrument_id: InstrumentId,
117    price_precision: u8,
118    size_precision: u8,
119    account_id: AccountId,
120    ts_init: UnixNanos,
121) -> anyhow::Result<FillReport> {
122    let client_order_id = ClientOrderId::new(decode_broker_id(
123        &msg.client_order_id,
124        BINANCE_NAUTILUS_SPOT_BROKER_ID,
125    ));
126    let venue_order_id = VenueOrderId::new(msg.order_id.to_string());
127    let trade_id = TradeId::new(msg.trade_id.to_string());
128    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
129
130    let order_side = match msg.side {
131        BinanceSide::Buy => OrderSide::Buy,
132        BinanceSide::Sell => OrderSide::Sell,
133    };
134
135    let liquidity_side = if msg.is_maker {
136        LiquiditySide::Maker
137    } else {
138        LiquiditySide::Taker
139    };
140
141    let last_qty: f64 = msg.last_filled_qty.parse().unwrap_or(0.0);
142    let last_px: f64 = msg.last_filled_price.parse().unwrap_or(0.0);
143    let commission: f64 = msg.commission.parse().unwrap_or(0.0);
144    let commission_currency = msg
145        .commission_asset
146        .as_ref()
147        .map_or_else(Currency::USDT, |a| {
148            Currency::get_or_create_crypto(a.as_str())
149        });
150
151    Ok(FillReport::new(
152        account_id,
153        instrument_id,
154        venue_order_id,
155        trade_id,
156        order_side,
157        Quantity::new(last_qty, size_precision),
158        Price::new(last_px, price_precision),
159        Money::new(commission, commission_currency),
160        liquidity_side,
161        Some(client_order_id),
162        None, // venue_position_id
163        ts_event,
164        ts_init,
165        None, // report_id
166    ))
167}
168
169/// Converts a Binance Spot account position update to a Nautilus account state.
170pub fn parse_spot_account_position(
171    msg: &BinanceSpotAccountPositionMsg,
172    account_id: AccountId,
173    ts_init: UnixNanos,
174) -> AccountState {
175    let ts_event = UnixNanos::from_millis(msg.event_time as u64);
176
177    let balances: Vec<AccountBalance> = msg
178        .balances
179        .iter()
180        .filter_map(|b| {
181            let total = b.free + b.locked;
182            let currency = Currency::get_or_create_crypto(b.asset.as_str());
183            AccountBalance::from_total_and_locked(total, b.locked, currency).ok()
184        })
185        .collect();
186
187    AccountState::new(
188        account_id,
189        AccountType::Cash,
190        balances,
191        vec![], // No margins for spot
192        true,   // is_reported
193        UUID4::new(),
194        ts_event,
195        ts_init,
196        None, // base_currency
197    )
198}
199
200fn parse_order_status(status: BinanceOrderStatus) -> OrderStatus {
201    match status {
202        BinanceOrderStatus::New | BinanceOrderStatus::PendingNew => OrderStatus::Accepted,
203        BinanceOrderStatus::PartiallyFilled => OrderStatus::PartiallyFilled,
204        BinanceOrderStatus::Filled
205        | BinanceOrderStatus::NewAdl
206        | BinanceOrderStatus::NewInsurance => OrderStatus::Filled,
207        BinanceOrderStatus::Canceled | BinanceOrderStatus::PendingCancel => OrderStatus::Canceled,
208        BinanceOrderStatus::Rejected => OrderStatus::Rejected,
209        BinanceOrderStatus::Expired | BinanceOrderStatus::ExpiredInMatch => OrderStatus::Expired,
210        BinanceOrderStatus::Unknown => OrderStatus::Accepted,
211    }
212}
213
214fn parse_spot_order_type(order_type: &str) -> OrderType {
215    match order_type {
216        "LIMIT" | "LIMIT_MAKER" => OrderType::Limit,
217        "MARKET" => OrderType::Market,
218        "STOP_LOSS" => OrderType::StopMarket,
219        "STOP_LOSS_LIMIT" => OrderType::StopLimit,
220        "TAKE_PROFIT" => OrderType::MarketIfTouched,
221        "TAKE_PROFIT_LIMIT" => OrderType::LimitIfTouched,
222        _ => OrderType::Market,
223    }
224}
225
226fn parse_time_in_force(tif: BinanceTimeInForce) -> TimeInForce {
227    match tif {
228        BinanceTimeInForce::Gtc | BinanceTimeInForce::Gtx => TimeInForce::Gtc,
229        BinanceTimeInForce::Ioc | BinanceTimeInForce::Rpi => TimeInForce::Ioc,
230        BinanceTimeInForce::Fok => TimeInForce::Fok,
231        BinanceTimeInForce::Gtd => TimeInForce::Gtd,
232        BinanceTimeInForce::Unknown => TimeInForce::Gtc,
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use rstest::rstest;
239
240    use super::*;
241    use crate::{
242        common::testing::load_fixture_string,
243        spot::websocket::trading::user_data::BinanceSpotExecutionReport,
244    };
245
246    const PRICE_PRECISION: u8 = 2;
247    const SIZE_PRECISION: u8 = 5;
248
249    fn instrument_id() -> InstrumentId {
250        InstrumentId::from("ETHUSDT.BINANCE")
251    }
252
253    #[rstest]
254    fn test_parse_execution_report_to_order_status_report() {
255        let json = load_fixture_string("spot/user_data_json/execution_report_new.json");
256        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
257        let account_id = AccountId::from("BINANCE-001");
258        let ts_init = UnixNanos::from(1_000_000_000u64);
259
260        let report = parse_spot_exec_report_to_order_status(
261            &msg,
262            instrument_id(),
263            PRICE_PRECISION,
264            SIZE_PRECISION,
265            account_id,
266            ts_init,
267        )
268        .unwrap();
269
270        assert_eq!(report.account_id, account_id);
271        assert_eq!(report.instrument_id, instrument_id());
272        assert_eq!(report.order_side, OrderSide::Buy);
273        assert_eq!(report.order_status, OrderStatus::Accepted);
274        assert_eq!(report.order_type, OrderType::Limit);
275        assert_eq!(report.time_in_force, TimeInForce::Gtc);
276        assert_eq!(report.venue_order_id, VenueOrderId::new("12345678"));
277        assert_eq!(
278            report.client_order_id,
279            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
280        );
281        assert_eq!(report.quantity, Quantity::new(1.0, SIZE_PRECISION));
282        assert_eq!(report.filled_qty, Quantity::new(0.0, SIZE_PRECISION));
283        assert_eq!(report.price, Some(Price::new(2500.0, PRICE_PRECISION)));
284        assert!(report.avg_px.is_none());
285        assert!(!report.post_only);
286        assert!(report.trigger_price.is_none());
287        assert_eq!(
288            report.ts_accepted,
289            UnixNanos::from(1_709_654_400_000_000_000u64)
290        );
291        assert_eq!(
292            report.ts_last,
293            UnixNanos::from(1_709_654_400_000_000_000u64)
294        );
295        assert_eq!(report.ts_init, ts_init);
296    }
297
298    #[rstest]
299    fn test_parse_execution_report_limit_maker_sets_post_only() {
300        let json = r#"{
301            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
302            "c":"x-TD67BGP9-T0000000000000","S":"SELL","o":"LIMIT_MAKER",
303            "f":"GTC","q":"0.5","p":"2600.00","P":"0",
304            "x":"NEW","X":"NEW","r":"NONE","i":12345679,
305            "l":"0","z":"0","L":"0","n":"0","N":null,
306            "T":1709654400000,"t":-1,"w":true,"m":false,
307            "O":1709654400000,"Z":"0","C":""
308        }"#;
309        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
310        let account_id = AccountId::from("BINANCE-001");
311        let ts_init = UnixNanos::from(1_000_000_000u64);
312
313        let report = parse_spot_exec_report_to_order_status(
314            &msg,
315            instrument_id(),
316            PRICE_PRECISION,
317            SIZE_PRECISION,
318            account_id,
319            ts_init,
320        )
321        .unwrap();
322
323        assert_eq!(report.order_type, OrderType::Limit);
324        assert!(report.post_only, "LIMIT_MAKER must set post_only");
325    }
326
327    #[rstest]
328    fn test_parse_execution_report_partial_fill_computes_avg_px() {
329        let json = r#"{
330            "e":"executionReport","E":1709654400000,"s":"ETHUSDT",
331            "c":"x-TD67BGP9-T0000000000000","S":"BUY","o":"LIMIT",
332            "f":"GTC","q":"2.0","p":"2500.00","P":"0",
333            "x":"TRADE","X":"PARTIALLY_FILLED","r":"NONE","i":12345678,
334            "l":"0.5","z":"0.5","L":"2499.00","n":"0.00100000","N":"ETH",
335            "T":1709654400000,"t":98765432,"w":true,"m":false,
336            "O":1709654400000,"Z":"1249.50","C":""
337        }"#;
338        let msg: BinanceSpotExecutionReport = serde_json::from_str(json).unwrap();
339        let account_id = AccountId::from("BINANCE-001");
340        let ts_init = UnixNanos::from(1_000_000_000u64);
341
342        let report = parse_spot_exec_report_to_order_status(
343            &msg,
344            instrument_id(),
345            PRICE_PRECISION,
346            SIZE_PRECISION,
347            account_id,
348            ts_init,
349        )
350        .unwrap();
351
352        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
353        assert_eq!(report.quantity, Quantity::new(2.0, SIZE_PRECISION));
354        assert_eq!(report.filled_qty, Quantity::new(0.5, SIZE_PRECISION));
355
356        // avg_px = cum_quote / filled_qty = 1249.50 / 0.5 = 2499.00
357        assert_eq!(report.avg_px.unwrap().to_string(), "2499.00");
358    }
359
360    #[rstest]
361    fn test_parse_execution_report_stop_loss_has_trigger_price() {
362        let json = load_fixture_string("spot/user_data_json/execution_report_stop_loss.json");
363        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
364        let account_id = AccountId::from("BINANCE-001");
365        let ts_init = UnixNanos::from(1_000_000_000u64);
366
367        let report = parse_spot_exec_report_to_order_status(
368            &msg,
369            instrument_id(),
370            PRICE_PRECISION,
371            SIZE_PRECISION,
372            account_id,
373            ts_init,
374        )
375        .unwrap();
376
377        assert_eq!(report.order_type, OrderType::StopLimit);
378        assert_eq!(report.order_side, OrderSide::Sell);
379        assert_eq!(
380            report.client_order_id,
381            Some(ClientOrderId::from("O-20200101-000000-000-000-1")),
382        );
383        assert_eq!(
384            report.trigger_price,
385            Some(Price::new(2450.0, PRICE_PRECISION))
386        );
387        assert_eq!(report.price, Some(Price::new(2400.0, PRICE_PRECISION)));
388    }
389
390    #[rstest]
391    fn test_parse_execution_report_to_fill_report() {
392        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
393        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
394        let account_id = AccountId::from("BINANCE-001");
395        let ts_init = UnixNanos::from(1_000_000_000u64);
396
397        let report = parse_spot_exec_report_to_fill(
398            &msg,
399            instrument_id(),
400            PRICE_PRECISION,
401            SIZE_PRECISION,
402            account_id,
403            ts_init,
404        )
405        .unwrap();
406
407        assert_eq!(report.account_id, account_id);
408        assert_eq!(report.instrument_id, instrument_id());
409        assert_eq!(report.order_side, OrderSide::Buy);
410        assert_eq!(report.liquidity_side, LiquiditySide::Maker);
411        assert_eq!(report.trade_id, TradeId::new("98765432"));
412        assert_eq!(
413            report.client_order_id,
414            Some(ClientOrderId::from("O-20200101-000000-000-000-0")),
415        );
416    }
417
418    #[rstest]
419    fn test_parse_account_position() {
420        let json = load_fixture_string("spot/user_data_json/account_position.json");
421        let msg: BinanceSpotAccountPositionMsg = 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 state = parse_spot_account_position(&msg, account_id, ts_init);
426
427        assert_eq!(state.account_id, account_id);
428        assert_eq!(state.account_type, AccountType::Cash);
429        assert!(state.is_reported);
430        assert_eq!(state.balances.len(), 2);
431    }
432
433    // Regression for the #3867 bug class: WS `free` and `locked` with more decimal places
434    // than the asset's currency precision used to trip the invariant when Money::new rounded
435    // each side independently.
436    #[rstest]
437    fn test_parse_account_position_precision_drift() {
438        let json = r#"{
439            "e": "outboundAccountPosition",
440            "E": 1700000000000,
441            "u": 1700000000000,
442            "B": [{
443                "a": "ETH",
444                "f": "9.999999994999",
445                "l": "0.000000040000"
446            }]
447        }"#;
448        let msg: BinanceSpotAccountPositionMsg = serde_json::from_str(json).unwrap();
449        let account_id = AccountId::from("BINANCE-001");
450        let ts_init = UnixNanos::from(1_000_000_000u64);
451
452        let state = parse_spot_account_position(&msg, account_id, ts_init);
453
454        assert_eq!(state.balances.len(), 1);
455        let balance = &state.balances[0];
456        assert_eq!(balance.total.raw, balance.locked.raw + balance.free.raw);
457    }
458}