Skip to main content

nautilus_binance/spot/websocket/trading/
user_data.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//! Binance Spot User Data Stream message types.
17//!
18//! Pure venue types with no Nautilus model imports. Execution reports and account
19//! updates map to JSON payloads. List-status events decode from SBE template 606.
20
21use nautilus_core::serialization::deserialize_decimal_from_str;
22use rust_decimal::Decimal;
23use serde::Deserialize;
24use ustr::Ustr;
25
26use crate::{
27    common::enums::{BinanceOrderStatus, BinanceSide, BinanceTimeInForce},
28    spot::sbe::spot::{
29        contingency_type::ContingencyType, list_order_status::ListOrderStatus,
30        list_status_type::ListStatusType,
31    },
32};
33
34/// Spot-specific execution type for order updates.
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
36#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
37pub enum BinanceSpotExecutionType {
38    /// New order accepted.
39    New,
40    /// Order canceled.
41    Canceled,
42    /// Order replaced (cancel-replace).
43    Replaced,
44    /// Order rejected.
45    Rejected,
46    /// Trade (partial or full fill).
47    Trade,
48    /// Order expired (IOC/FOK not filled, or GTD expiration).
49    Expired,
50    /// Self-trade prevention triggered.
51    TradePrevention,
52    /// Unknown or undocumented execution type.
53    #[serde(other)]
54    Unknown,
55}
56
57/// Execution report event (`executionReport`) from the Spot user data stream.
58///
59/// Contains all fields needed to determine order lifecycle state and fill details.
60///
61/// # References
62///
63/// - <https://developers.binance.com/docs/binance-spot-api-docs/user-data-stream/event-order-update>
64#[derive(Debug, Clone, Deserialize)]
65pub struct BinanceSpotExecutionReport {
66    /// Event type ("executionReport").
67    #[serde(rename = "e")]
68    pub event_type: String,
69    /// Event time in milliseconds.
70    #[serde(rename = "E")]
71    pub event_time: i64,
72    /// Symbol.
73    #[serde(rename = "s")]
74    pub symbol: Ustr,
75    /// Client order ID.
76    #[serde(rename = "c")]
77    pub client_order_id: String,
78    /// Side.
79    #[serde(rename = "S")]
80    pub side: BinanceSide,
81    /// Order type (LIMIT, MARKET, STOP_LOSS, etc.).
82    #[serde(rename = "o")]
83    pub order_type: String,
84    /// Time in force.
85    #[serde(rename = "f")]
86    pub time_in_force: BinanceTimeInForce,
87    /// Original quantity.
88    #[serde(rename = "q")]
89    pub original_qty: String,
90    /// Original price.
91    #[serde(rename = "p")]
92    pub price: String,
93    /// Stop price.
94    #[serde(rename = "P")]
95    pub stop_price: String,
96    /// Current execution type.
97    #[serde(rename = "x")]
98    pub execution_type: BinanceSpotExecutionType,
99    /// Current order status.
100    #[serde(rename = "X")]
101    pub order_status: BinanceOrderStatus,
102    /// Order reject reason (only for Rejected).
103    #[serde(rename = "r")]
104    pub reject_reason: String,
105    /// Order ID.
106    #[serde(rename = "i")]
107    pub order_id: i64,
108    /// Last executed quantity.
109    #[serde(rename = "l")]
110    pub last_filled_qty: String,
111    /// Cumulative filled quantity.
112    #[serde(rename = "z")]
113    pub cumulative_filled_qty: String,
114    /// Last executed price.
115    #[serde(rename = "L")]
116    pub last_filled_price: String,
117    /// Commission amount.
118    #[serde(rename = "n")]
119    pub commission: String,
120    /// Commission asset.
121    #[serde(rename = "N", default)]
122    pub commission_asset: Option<Ustr>,
123    /// Transaction time in milliseconds.
124    #[serde(rename = "T")]
125    pub transaction_time: i64,
126    /// Trade ID (-1 if not a trade).
127    #[serde(rename = "t")]
128    pub trade_id: i64,
129    /// Is the order on the book?
130    #[serde(rename = "w")]
131    pub is_working: bool,
132    /// Is this a maker trade?
133    #[serde(rename = "m")]
134    pub is_maker: bool,
135    /// Order creation time in milliseconds.
136    #[serde(rename = "O")]
137    pub order_creation_time: i64,
138    /// Cumulative quote asset transacted quantity.
139    #[serde(rename = "Z")]
140    pub cumulative_quote_qty: String,
141    /// Original client order ID (for cancel-replace).
142    #[serde(rename = "C", default)]
143    pub original_client_order_id: Option<String>,
144    /// Expiry reason for expired orders.
145    #[serde(rename = "eR", default)]
146    pub expiry_reason: Option<String>,
147}
148
149impl BinanceSpotExecutionReport {
150    /// Returns the client order ID of the order this report belongs to.
151    ///
152    /// When a report results from a cancel request, Binance carries the request's
153    /// ID in `c` and the ID of the order being canceled in `C`, which is empty
154    /// otherwise.
155    #[must_use]
156    pub fn order_client_order_id(&self) -> &str {
157        match self.original_client_order_id.as_deref() {
158            Some(id) if !id.is_empty() => id,
159            _ => &self.client_order_id,
160        }
161    }
162}
163
164/// Binance Spot `ListStatusEvent` from SBE template 606.
165///
166/// Both timestamps retain microsecond precision from the wire. Child symbols match
167/// the list symbol. The subscription ID is absent for schema version 0 or an SBE
168/// null value. An empty reject reason or `NONE` indicates no rejection.
169///
170/// # References
171///
172/// [Spot SBE schema 3:5](https://github.com/binance/binance-spot-api-docs/blob/master/sbe/schemas/spot_3_5.xml).
173#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct BinanceSpotListStatus {
175    pub event_time_us: i64,
176    pub transaction_time_us: i64,
177    pub order_list_id: i64,
178    pub contingency_type: ContingencyType,
179    pub list_status_type: ListStatusType,
180    pub list_order_status: ListOrderStatus,
181    pub subscription_id: Option<u16>,
182    pub orders: Vec<BinanceSpotListOrder>,
183    pub symbol: Ustr,
184    pub list_client_order_id: String,
185    pub reject_reason: String,
186}
187
188/// Child identity within a Spot order-list lifecycle event.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct BinanceSpotListOrder {
191    pub order_id: i64,
192    pub symbol: Ustr,
193    pub client_order_id: String,
194}
195
196/// Account position update event (`outboundAccountPosition`).
197///
198/// Sent whenever there is a balance change (not associated with an order).
199///
200/// # References
201///
202/// - <https://developers.binance.com/docs/binance-spot-api-docs/user-data-stream/event-outbound-account-position>
203#[derive(Debug, Clone, Deserialize)]
204pub struct BinanceSpotAccountPositionMsg {
205    /// Event type ("outboundAccountPosition").
206    #[serde(rename = "e")]
207    pub event_type: String,
208    /// Event time in milliseconds.
209    #[serde(rename = "E")]
210    pub event_time: i64,
211    /// Last account update time.
212    #[serde(rename = "u")]
213    pub last_update_time: i64,
214    /// Account balances.
215    #[serde(rename = "B")]
216    pub balances: Vec<BinanceSpotBalanceEntry>,
217}
218
219/// Individual balance entry within an account position update.
220#[derive(Debug, Clone, Deserialize)]
221pub struct BinanceSpotBalanceEntry {
222    /// Asset name.
223    #[serde(rename = "a")]
224    pub asset: Ustr,
225    /// Free balance.
226    #[serde(rename = "f", deserialize_with = "deserialize_decimal_from_str")]
227    pub free: Decimal,
228    /// Locked balance.
229    #[serde(rename = "l", deserialize_with = "deserialize_decimal_from_str")]
230    pub locked: Decimal,
231}
232
233/// Balance update event (`balanceUpdate`).
234///
235/// Sent when a deposit or withdrawal is processed, or when balances change
236/// outside of trading (e.g., interest, fees).
237///
238/// # References
239///
240/// - <https://developers.binance.com/docs/binance-spot-api-docs/user-data-stream/event-balance-update>
241#[derive(Debug, Clone, Deserialize)]
242pub struct BinanceSpotBalanceUpdateMsg {
243    /// Event type ("balanceUpdate").
244    #[serde(rename = "e")]
245    pub event_type: String,
246    /// Event time in milliseconds.
247    #[serde(rename = "E")]
248    pub event_time: i64,
249    /// Asset.
250    #[serde(rename = "a")]
251    pub asset: Ustr,
252    /// Balance delta.
253    #[serde(rename = "d")]
254    pub delta: String,
255    /// Clear time in milliseconds.
256    #[serde(rename = "T")]
257    pub clear_time: i64,
258}
259
260#[cfg(test)]
261mod tests {
262    use rstest::rstest;
263
264    use super::*;
265    use crate::common::testing::{load_event_fixture, load_fixture_string};
266
267    #[rstest]
268    fn test_deserialize_execution_report_new() {
269        let json = load_event_fixture("spot/user_data_json/execution_report_wrapped.json");
270        let msg: BinanceSpotExecutionReport = serde_json::from_value(json).unwrap();
271
272        assert_eq!(msg.event_type, "executionReport");
273        assert_eq!(msg.symbol.as_str(), "ETHBTC");
274        assert_eq!(msg.execution_type, BinanceSpotExecutionType::New);
275        assert_eq!(msg.order_status, BinanceOrderStatus::New);
276        assert_eq!(msg.order_id, 4293153);
277        assert_eq!(msg.side, BinanceSide::Buy);
278    }
279
280    #[rstest]
281    fn test_deserialize_execution_report_trade() {
282        let json = load_fixture_string("spot/user_data_json/execution_report_trade.json");
283        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
284
285        assert_eq!(msg.execution_type, BinanceSpotExecutionType::Trade);
286        assert_eq!(msg.order_status, BinanceOrderStatus::Filled);
287        assert_eq!(msg.trade_id, 98765432);
288        assert_eq!(msg.last_filled_qty, "1.00000000");
289        assert_eq!(msg.last_filled_price, "2500.00000000");
290        assert!(msg.is_maker);
291    }
292
293    #[rstest]
294    fn test_deserialize_execution_report_canceled() {
295        let json = load_fixture_string("spot/user_data_json/execution_report_canceled.json");
296        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
297
298        assert_eq!(msg.execution_type, BinanceSpotExecutionType::Canceled);
299        assert_eq!(msg.order_status, BinanceOrderStatus::Canceled);
300    }
301
302    #[rstest]
303    fn test_deserialize_execution_report_expiry_reason() {
304        let json = load_fixture_string("spot/user_data_json/execution_report_expired.json");
305        let msg: BinanceSpotExecutionReport = serde_json::from_str(&json).unwrap();
306
307        assert_eq!(msg.execution_type, BinanceSpotExecutionType::Expired);
308        assert_eq!(msg.order_status, BinanceOrderStatus::Expired);
309        assert_eq!(msg.expiry_reason.as_deref(), Some("INSUFFICIENT_LIQUIDITY"));
310    }
311
312    #[rstest]
313    fn test_deserialize_account_position() {
314        let json = load_event_fixture("spot/user_data_json/account_position_wrapped.json");
315        let msg: BinanceSpotAccountPositionMsg = serde_json::from_value(json).unwrap();
316
317        assert_eq!(msg.event_type, "outboundAccountPosition");
318        assert!(!msg.balances.is_empty());
319        assert_eq!(msg.balances[0].asset.as_str(), "ETH");
320    }
321
322    #[rstest]
323    fn test_deserialize_balance_update() {
324        let json = load_event_fixture("spot/user_data_json/balance_update_wrapped.json");
325        let msg: BinanceSpotBalanceUpdateMsg = serde_json::from_value(json).unwrap();
326
327        assert_eq!(msg.event_type, "balanceUpdate");
328        assert_eq!(msg.asset.as_str(), "BTC");
329        assert_eq!(msg.delta, "100.00000000");
330    }
331}