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