Skip to main content

binance/margin/ws/
incoming_message.rs

1//! Events pushed over a margin user data WebSocket stream.
2//!
3//! Margin shares the spot user data event shapes — `outboundAccountPosition`,
4//! `balanceUpdate`, `executionReport`, `listStatus` — with `executionReport`
5//! gaining an optional `isolatedSymbol` field when the order is placed in an
6//! isolated margin account.
7
8use rust_decimal::Decimal;
9use serde::Deserialize;
10
11use crate::{
12    Timestamp,
13    margin::{OrderSide, OrderStatus, OrderType, TimeInForce},
14    ws::ReceivedMessage,
15};
16
17/// Top-level frame received on a margin user data stream. The wire format is
18/// internally tagged by the `e` (event) field.
19#[derive(PartialEq, Deserialize, Debug)]
20#[serde(tag = "e")]
21#[allow(clippy::large_enum_variant)]
22pub enum IncomingMessage {
23    #[serde(rename = "outboundAccountPosition")]
24    OutboundAccountPosition(OutboundAccountPositionEvent),
25    #[serde(rename = "balanceUpdate")]
26    BalanceUpdate(BalanceUpdateEvent),
27    #[serde(rename = "executionReport")]
28    ExecutionReport(ExecutionReportEvent),
29}
30
31impl ReceivedMessage for IncomingMessage {
32    fn server_shutdown_event_time(&self) -> Option<u64> {
33        // Margin user data streams don't emit an app-level shutdown event;
34        // the connection is dropped at the protocol level when the listenKey
35        // expires. The shared driver handles that via its heartbeat / close
36        // path.
37        None
38    }
39}
40
41/// Account-wide balance snapshot pushed whenever a balance changes.
42///
43/// Carries the full per-asset state — easier to consume than reconciling
44/// individual `BalanceUpdate` deltas.
45#[derive(PartialEq, Deserialize, Debug)]
46pub struct OutboundAccountPositionEvent {
47    /// Event time.
48    #[serde(rename = "E")]
49    pub event_time: Timestamp,
50    /// Account last-update time (matches the corresponding REST snapshot).
51    #[serde(rename = "u")]
52    pub last_account_update: Timestamp,
53    #[serde(rename = "B")]
54    pub balances: Vec<AccountBalance>,
55}
56
57#[derive(PartialEq, Deserialize, Debug)]
58pub struct AccountBalance {
59    #[serde(rename = "a")]
60    pub asset: String,
61    #[serde(rename = "f")]
62    pub free: Decimal,
63    #[serde(rename = "l")]
64    pub locked: Decimal,
65}
66
67/// Single-asset balance change (deposit, withdrawal, transfer between wallets).
68#[derive(PartialEq, Deserialize, Debug)]
69pub struct BalanceUpdateEvent {
70    #[serde(rename = "E")]
71    pub event_time: Timestamp,
72    #[serde(rename = "a")]
73    pub asset: String,
74    /// Signed delta — positive for credits, negative for debits.
75    #[serde(rename = "d")]
76    pub delta: Decimal,
77    /// Clear time (when the balance change was applied).
78    #[serde(rename = "T")]
79    pub clear_time: Timestamp,
80}
81
82/// Order lifecycle event: NEW, TRADE, CANCELED, REPLACED, REJECTED, EXPIRED,
83/// TRADE_PREVENTION.
84///
85/// One event is emitted for every state transition; consume the stream to
86/// reconcile order state without polling.
87#[derive(PartialEq, Deserialize, Debug)]
88pub struct ExecutionReportEvent {
89    #[serde(rename = "E")]
90    pub event_time: Timestamp,
91    #[serde(rename = "s")]
92    pub symbol: String,
93    /// Caller-supplied client order id.
94    #[serde(rename = "c")]
95    pub client_order_id: String,
96    #[serde(rename = "S")]
97    pub side: OrderSide,
98    #[serde(rename = "o")]
99    pub order_type: OrderType,
100    #[serde(rename = "f")]
101    pub time_in_force: TimeInForce,
102    /// Order quantity (base asset).
103    #[serde(rename = "q")]
104    pub orig_qty: Decimal,
105    #[serde(rename = "p")]
106    pub price: Decimal,
107    /// Stop price (for STOP_LOSS / TAKE_PROFIT variants).
108    #[serde(rename = "P")]
109    pub stop_price: Decimal,
110    /// Iceberg quantity.
111    #[serde(rename = "F")]
112    pub iceberg_qty: Decimal,
113    /// Original client order id (set on cancel / replace).
114    #[serde(rename = "C")]
115    pub orig_client_order_id: String,
116    /// Current execution type — NEW / CANCELED / REPLACED / REJECTED / TRADE /
117    /// EXPIRED / TRADE_PREVENTION. Returned as a raw string to keep this
118    /// resilient to new variants Binance may add.
119    #[serde(rename = "x")]
120    pub execution_type: String,
121    #[serde(rename = "X")]
122    pub order_status: OrderStatus,
123    /// Reject reason (or `"NONE"`).
124    #[serde(rename = "r")]
125    pub reject_reason: String,
126    #[serde(rename = "i")]
127    pub order_id: i64,
128    /// Quantity executed in this trade (0 if no fill).
129    #[serde(rename = "l")]
130    pub last_executed_qty: Decimal,
131    /// Cumulative filled quantity.
132    #[serde(rename = "z")]
133    pub cumulative_filled_qty: Decimal,
134    /// Price of this trade (0 if no fill).
135    #[serde(rename = "L")]
136    pub last_executed_price: Decimal,
137    /// Commission for this fill.
138    #[serde(rename = "n")]
139    pub commission: Decimal,
140    /// Commission asset — null if no fill.
141    #[serde(rename = "N")]
142    pub commission_asset: Option<String>,
143    #[serde(rename = "T")]
144    pub transaction_time: Timestamp,
145    /// Trade id — -1 if no fill.
146    #[serde(rename = "t")]
147    pub trade_id: i64,
148    /// Whether the order is currently on the book.
149    #[serde(rename = "w")]
150    pub is_on_book: bool,
151    /// Whether this trade was the maker side.
152    #[serde(rename = "m")]
153    pub is_maker: bool,
154    #[serde(rename = "O")]
155    pub order_creation_time: Timestamp,
156    /// Cumulative quote-asset value transacted.
157    #[serde(rename = "Z")]
158    pub cumulative_quote_qty: Decimal,
159    /// Quote-asset value of this fill.
160    #[serde(rename = "Y")]
161    pub last_quote_qty: Decimal,
162    /// Quote order qty (when the order was placed by quote amount).
163    #[serde(rename = "Q")]
164    pub quote_order_qty: Decimal,
165    /// Margin-only: symbol of the isolated margin account this order belongs
166    /// to. Absent (== None) for cross-margin orders.
167    #[serde(rename = "isolatedSymbol", default)]
168    pub isolated_symbol: Option<String>,
169}
170
171#[cfg(test)]
172mod tests {
173    use rust_decimal::dec;
174
175    use super::*;
176    use crate::serde::deserialize_json;
177
178    #[test]
179    fn deserialize_outbound_account_position() {
180        let json = r#"{
181            "e": "outboundAccountPosition",
182            "E": 1564034571105,
183            "u": 1564034571073,
184            "B": [
185                {"a": "ETH", "f": "10000.000000", "l": "0.000000"},
186                {"a": "USDT", "f": "1000.50", "l": "100.00"}
187            ]
188        }"#;
189        let parsed: IncomingMessage = deserialize_json(json).unwrap();
190        let IncomingMessage::OutboundAccountPosition(event) = parsed else {
191            panic!("expected OutboundAccountPosition, got {parsed:?}");
192        };
193        assert_eq!(event.event_time, 1564034571105);
194        assert_eq!(event.last_account_update, 1564034571073);
195        assert_eq!(event.balances.len(), 2);
196        assert_eq!(event.balances[1].asset, "USDT");
197        assert_eq!(event.balances[1].free, dec!(1000.50));
198        assert_eq!(event.balances[1].locked, dec!(100.00));
199    }
200
201    #[test]
202    fn deserialize_balance_update() {
203        let json = r#"{
204            "e": "balanceUpdate",
205            "E": 1573200697110,
206            "a": "BTC",
207            "d": "100.00000000",
208            "T": 1573200697068
209        }"#;
210        let parsed: IncomingMessage = deserialize_json(json).unwrap();
211        let IncomingMessage::BalanceUpdate(event) = parsed else {
212            panic!("expected BalanceUpdate, got {parsed:?}");
213        };
214        assert_eq!(event.asset, "BTC");
215        assert_eq!(event.delta, dec!(100.0));
216    }
217
218    #[test]
219    fn deserialize_execution_report_cross_margin_omits_isolated_symbol() {
220        let json = r#"{
221            "e": "executionReport",
222            "E": 1499405658658,
223            "s": "ETHBTC",
224            "c": "myOrderId",
225            "S": "BUY",
226            "o": "LIMIT",
227            "f": "GTC",
228            "q": "1.00000000",
229            "p": "0.10264410",
230            "P": "0.00000000",
231            "F": "0.00000000",
232            "C": "",
233            "x": "NEW",
234            "X": "NEW",
235            "r": "NONE",
236            "i": 4293153,
237            "l": "0.00000000",
238            "z": "0.00000000",
239            "L": "0.00000000",
240            "n": "0",
241            "N": null,
242            "T": 1499405658657,
243            "t": -1,
244            "w": true,
245            "m": false,
246            "O": 1499405658657,
247            "Z": "0.00000000",
248            "Y": "0.00000000",
249            "Q": "0.00000000"
250        }"#;
251        let parsed: IncomingMessage = deserialize_json(json).unwrap();
252        let IncomingMessage::ExecutionReport(event) = parsed else {
253            panic!("expected ExecutionReport, got {parsed:?}");
254        };
255        assert_eq!(event.symbol, "ETHBTC");
256        assert_eq!(event.order_id, 4293153);
257        assert_eq!(event.order_status, OrderStatus::New);
258        assert_eq!(event.execution_type, "NEW");
259        assert_eq!(event.commission_asset, None);
260        assert_eq!(event.isolated_symbol, None);
261    }
262
263    #[test]
264    fn deserialize_execution_report_isolated_margin_carries_isolated_symbol() {
265        // Same as above plus `isolatedSymbol`.
266        let json = r#"{
267            "e": "executionReport",
268            "E": 1499405658658,
269            "s": "ETHBTC",
270            "c": "myOrderId",
271            "S": "SELL",
272            "o": "MARKET",
273            "f": "IOC",
274            "q": "1.00000000",
275            "p": "0.00000000",
276            "P": "0.00000000",
277            "F": "0.00000000",
278            "C": "",
279            "x": "TRADE",
280            "X": "FILLED",
281            "r": "NONE",
282            "i": 4293153,
283            "l": "1.00000000",
284            "z": "1.00000000",
285            "L": "0.10264410",
286            "n": "0.00010264",
287            "N": "BTC",
288            "T": 1499405658657,
289            "t": 12345,
290            "w": false,
291            "m": true,
292            "O": 1499405658657,
293            "Z": "0.10264410",
294            "Y": "0.10264410",
295            "Q": "0.00000000",
296            "isolatedSymbol": "ETHBTC"
297        }"#;
298        let parsed: IncomingMessage = deserialize_json(json).unwrap();
299        let IncomingMessage::ExecutionReport(event) = parsed else {
300            panic!("expected ExecutionReport, got {parsed:?}");
301        };
302        assert_eq!(event.isolated_symbol.as_deref(), Some("ETHBTC"));
303        assert_eq!(event.commission_asset.as_deref(), Some("BTC"));
304        assert!(event.is_maker);
305    }
306}