Skip to main content

bybit/
state.rs

1//! Local copy of a user's account: open orders, positions and wallet balances.
2//!
3//! Every SDK user who trades needs the same bookkeeping: load REST snapshots,
4//! then keep them current with the private `order`, `position` and `wallet`
5//! WebSocket topics, without letting a late or out-of-order message overwrite
6//! newer data. [`UserState`] does exactly that.
7//!
8//! # Typical flow
9//! 1. Connect the private stream, authenticate and subscribe to `order`,
10//!    `position` and `wallet`. Feed every [`TopicMessage`] into
11//!    [`UserState::apply`] from now on.
12//! 2. Load the REST snapshots (`get_open_closed_orders_all`,
13//!    `get_position_info_all`, `get_wallet_balance`) and pass them to the
14//!    `set_*` methods together with the response `time`.
15//! 3. After every reconnect repeat step 2: messages missed while
16//!    disconnected are only recovered by a fresh snapshot.
17//!
18//! Steps 1 and 2 may overlap: stream messages and snapshots are merged by
19//! `updated_time`, so the newer version of an order or position wins
20//! regardless of arrival order.
21//!
22//! Executions (`execution`, `execution.fast`) are a feed of events, not state,
23//! and are ignored by [`UserState::apply`].
24
25use std::collections::{HashMap, HashSet, VecDeque};
26
27use crate::{
28    AccountType, Category, PositionIdx, Timestamp,
29    http::{Order, Position, WalletBalance},
30    ws::{OrderMsg, PositionMsg, TopicMessage, WalletMsg},
31};
32
33/// Maximum number of closed orders remembered to reject late stream updates
34/// and stale snapshot entries for them.
35pub const MAX_CLOSED_ORDERS: usize = 10_000;
36
37/// Identifies an order. Order ids are unique only within a category.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct OrderKey {
40    pub category: Category,
41    pub order_id: String,
42}
43
44/// Identifies a position: one per symbol in one-way mode, two (buy and sell)
45/// in hedge mode.
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
47pub struct PositionKey {
48    pub category: Category,
49    pub symbol: String,
50    pub position_idx: PositionIdx,
51}
52
53/// What [`UserState::apply`] and friends changed. Updates that were older than
54/// the stored data are ignored and not reported.
55#[derive(Debug)]
56pub enum Change {
57    /// A new open order appeared.
58    OrderOpened(OrderKey),
59    /// An open order changed (e.g. partially filled or amended).
60    OrderUpdated(OrderKey),
61    /// An order reached a final status and was removed. `order` is its final
62    /// state.
63    OrderClosed { key: OrderKey, order: Box<Order> },
64    /// A position was created or changed (including closing to size 0).
65    PositionUpdated(PositionKey),
66    /// The balance of an account type was replaced.
67    WalletUpdated(AccountType),
68}
69
70/// Open orders, positions and wallet balances of one account.
71///
72/// Plain data (`Send + Sync`): it can live in any task. It does no I/O.
73#[derive(Debug, Default)]
74pub struct UserState {
75    wallets: HashMap<AccountType, WalletBalance>,
76    orders: HashMap<OrderKey, Order>,
77    closed_orders: ClosedOrders,
78    positions: HashMap<PositionKey, Position>,
79}
80
81impl UserState {
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    // -- Snapshots ----------------------------------------------------------
87
88    /// Replace the balance of `balance.account_type` with a REST snapshot.
89    pub fn set_wallet(&mut self, balance: WalletBalance) {
90        self.wallets.insert(balance.account_type, balance);
91    }
92
93    /// Merge a REST snapshot of the open orders of `category`.
94    ///
95    /// `snapshot_time` is the server time of the response
96    /// ([`Response::time`](crate::http::Response::time)), milliseconds.
97    /// - an order from the snapshot replaces the stored one unless the stored
98    ///   one is newer, and is skipped if it was already closed by the stream;
99    /// - a stored order missing from the snapshot is removed, unless it was
100    ///   updated after `snapshot_time` (it was opened after the snapshot).
101    pub fn set_orders(&mut self, category: Category, orders: Vec<Order>, snapshot_time: Timestamp) {
102        let mut in_snapshot = HashSet::with_capacity(orders.len());
103        for order in orders {
104            if !order.is_open_status() {
105                continue;
106            }
107            let key = OrderKey {
108                category,
109                order_id: order.order_id.clone(),
110            };
111            in_snapshot.insert(key.clone());
112            if self.closed_orders.closed_since(&key, order.updated_time) {
113                continue;
114            }
115            match self.orders.get(&key) {
116                Some(stored) if stored.updated_time > order.updated_time => {}
117                _ => {
118                    self.orders.insert(key, order);
119                }
120            }
121        }
122
123        self.orders.retain(|key, order| {
124            key.category != category
125                || in_snapshot.contains(key)
126                || order.updated_time > snapshot_time
127        });
128    }
129
130    /// Merge a REST snapshot of the positions of `category`.
131    ///
132    /// A snapshot position replaces the stored one unless the stored one is
133    /// newer. Stored positions missing from the snapshot are kept only if
134    /// they were updated after `snapshot_time`.
135    pub fn set_positions(
136        &mut self,
137        category: Category,
138        positions: Vec<Position>,
139        snapshot_time: Timestamp,
140    ) {
141        let mut in_snapshot = HashSet::with_capacity(positions.len());
142        for position in positions {
143            let key = PositionKey {
144                category,
145                symbol: position.symbol.clone(),
146                position_idx: position.position_idx,
147            };
148            in_snapshot.insert(key.clone());
149            match self.positions.get(&key) {
150                Some(stored) if is_newer_position(stored, &position) => {}
151                _ => {
152                    self.positions.insert(key, position);
153                }
154            }
155        }
156
157        self.positions.retain(|key, position| {
158            key.category != category
159                || in_snapshot.contains(key)
160                || position.updated_time > snapshot_time
161        });
162    }
163
164    // -- Stream -------------------------------------------------------------
165
166    /// Apply one private stream message. Topics other than `order`,
167    /// `position` and `wallet` are ignored.
168    pub fn apply(&mut self, msg: TopicMessage) -> Vec<Change> {
169        match msg {
170            TopicMessage::Order(msg) => msg
171                .data
172                .into_iter()
173                .filter_map(|order| self.apply_order(order))
174                .collect(),
175            TopicMessage::Position(msg) => msg
176                .data
177                .into_iter()
178                .filter_map(|position| self.apply_position(position))
179                .collect(),
180            TopicMessage::Wallet(msg) => msg
181                .data
182                .into_iter()
183                .map(|wallet| self.apply_wallet(wallet))
184                .collect(),
185            TopicMessage::Execution(_)
186            | TopicMessage::FastExecution(_)
187            | TopicMessage::Greeks(_)
188            | TopicMessage::Dcp(_) => Vec::new(),
189        }
190    }
191
192    /// Apply one `order` stream update. Returns `None` if it was stale.
193    pub fn apply_order(&mut self, msg: OrderMsg) -> Option<Change> {
194        let key = OrderKey {
195            category: msg.category,
196            order_id: msg.order_id.clone(),
197        };
198        if self.closed_orders.closed_since(&key, msg.updated_time) {
199            return None;
200        }
201
202        let open = msg.order_status.is_open();
203        match self.orders.get_mut(&key) {
204            Some(stored) if stored.updated_time > msg.updated_time => None,
205            Some(stored) if open => {
206                stored.update(msg);
207                Some(Change::OrderUpdated(key))
208            }
209            Some(_) => {
210                let mut order = self.orders.remove(&key)?;
211                let updated_time = msg.updated_time;
212                order.update(msg);
213                self.closed_orders.insert(key.clone(), updated_time);
214                Some(Change::OrderClosed {
215                    key,
216                    order: Box::new(order),
217                })
218            }
219            None if open => {
220                self.orders.insert(key.clone(), msg.into());
221                Some(Change::OrderOpened(key))
222            }
223            None => {
224                // Closed before we ever saw it open (e.g. an IOC order).
225                self.closed_orders.insert(key.clone(), msg.updated_time);
226                Some(Change::OrderClosed {
227                    key,
228                    order: Box::new(msg.into()),
229                })
230            }
231        }
232    }
233
234    /// Apply one `position` stream update. Returns `None` if it was stale.
235    pub fn apply_position(&mut self, msg: PositionMsg) -> Option<Change> {
236        let key = PositionKey {
237            category: msg.category,
238            symbol: msg.symbol.clone(),
239            position_idx: msg.position_idx,
240        };
241        let position = Position::from(msg);
242        if let Some(stored) = self.positions.get(&key)
243            && is_newer_position(stored, &position)
244        {
245            return None;
246        }
247        self.positions.insert(key.clone(), position);
248        Some(Change::PositionUpdated(key))
249    }
250
251    /// Apply one `wallet` stream update (always a full balance).
252    pub fn apply_wallet(&mut self, msg: WalletMsg) -> Change {
253        let account_type = msg.account_type;
254        self.wallets.insert(account_type, msg.into());
255        Change::WalletUpdated(account_type)
256    }
257
258    // -- Queries ------------------------------------------------------------
259
260    pub fn wallet(&self, account_type: AccountType) -> Option<&WalletBalance> {
261        self.wallets.get(&account_type)
262    }
263
264    pub fn wallets(&self) -> impl Iterator<Item = &WalletBalance> {
265        self.wallets.values()
266    }
267
268    pub fn order(&self, category: Category, order_id: &str) -> Option<&Order> {
269        self.orders.get(&OrderKey {
270            category,
271            order_id: order_id.to_owned(),
272        })
273    }
274
275    pub fn order_by_link_id(&self, category: Category, order_link_id: &str) -> Option<&Order> {
276        self.orders.iter().find_map(|(key, order)| {
277            (key.category == category && order.order_link_id.as_deref() == Some(order_link_id))
278                .then_some(order)
279        })
280    }
281
282    /// All open orders.
283    pub fn orders(&self) -> impl Iterator<Item = (&OrderKey, &Order)> {
284        self.orders.iter()
285    }
286
287    /// Open orders of one symbol.
288    pub fn orders_for<'a>(
289        &'a self,
290        category: Category,
291        symbol: &'a str,
292    ) -> impl Iterator<Item = &'a Order> {
293        self.orders
294            .iter()
295            .filter(move |(key, order)| key.category == category && order.symbol == symbol)
296            .map(|(_, order)| order)
297    }
298
299    pub fn position(
300        &self,
301        category: Category,
302        symbol: &str,
303        position_idx: PositionIdx,
304    ) -> Option<&Position> {
305        self.positions.get(&PositionKey {
306            category,
307            symbol: symbol.to_owned(),
308            position_idx,
309        })
310    }
311
312    /// All known positions, including closed ones (size 0), which still carry
313    /// the leverage and risk limit settings.
314    pub fn positions(&self) -> impl Iterator<Item = (&PositionKey, &Position)> {
315        self.positions.iter()
316    }
317
318    /// Positions with a non-zero size.
319    pub fn open_positions(&self) -> impl Iterator<Item = (&PositionKey, &Position)> {
320        self.positions
321            .iter()
322            .filter(|(_, position)| !position.size.is_zero())
323    }
324}
325
326/// `true` if `stored` is newer than `incoming` and must be kept.
327///
328/// `updated_time` decides; on a tie the trade sequence `seq` does, when both
329/// are known (Bybit sends `-1` for updates not caused by a trade).
330fn is_newer_position(stored: &Position, incoming: &Position) -> bool {
331    if stored.updated_time != incoming.updated_time {
332        return stored.updated_time > incoming.updated_time;
333    }
334    stored.seq >= 0 && incoming.seq >= 0 && stored.seq > incoming.seq
335}
336
337/// Bounded memory of recently closed orders: key -> `updated_time` of the
338/// closing update. The oldest entries are forgotten first.
339#[derive(Debug, Default)]
340struct ClosedOrders {
341    by_key: HashMap<OrderKey, Timestamp>,
342    order: VecDeque<OrderKey>,
343}
344
345impl ClosedOrders {
346    fn insert(&mut self, key: OrderKey, updated_time: Timestamp) {
347        if self.by_key.insert(key.clone(), updated_time).is_none() {
348            self.order.push_back(key);
349        }
350        while self.order.len() > MAX_CLOSED_ORDERS {
351            if let Some(oldest) = self.order.pop_front() {
352                self.by_key.remove(&oldest);
353            }
354        }
355    }
356
357    /// `true` if the order was closed by an update at least as new as
358    /// `updated_time`, i.e. data with that timestamp is stale.
359    ///
360    /// A conditional order that is triggered (a final status) and then
361    /// continues as a regular order under the same id gets a newer
362    /// `updated_time`, so it is not blocked.
363    fn closed_since(&self, key: &OrderKey, updated_time: Timestamp) -> bool {
364        self.by_key
365            .get(key)
366            .is_some_and(|&closed_at| closed_at >= updated_time)
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use crate::{OrderStatus, ws::IncomingMessage};
374    use rust_decimal::Decimal;
375
376    const LINEAR: Category = Category::Linear;
377
378    fn order_msg(order_id: &str, status: &str, updated_time: Timestamp) -> OrderMsg {
379        let json = format!(
380            r#"{{
381                "category": "linear", "symbol": "BTCUSDT", "orderId": "{order_id}",
382                "orderLinkId": "link-{order_id}", "side": "Buy", "orderType": "Limit",
383                "cancelType": "UNKNOWN", "price": "100", "qty": "2", "orderIv": "",
384                "timeInForce": "GTC", "orderStatus": "{status}", "lastPriceOnCreated": "",
385                "reduceOnly": false, "leavesQty": "", "leavesValue": "", "cumExecQty": "0",
386                "cumExecValue": "0", "avgPrice": "", "blockTradeId": "", "positionIdx": 0,
387                "cumExecFee": "0", "closedPnl": "0", "createdTime": "1000",
388                "updatedTime": "{updated_time}", "rejectReason": "EC_NoError",
389                "stopOrderType": "", "tpslMode": "", "triggerPrice": "", "takeProfit": "",
390                "stopLoss": "", "tpTriggerBy": "", "slTriggerBy": "", "tpLimitPrice": "",
391                "slLimitPrice": "", "triggerDirection": 0, "triggerBy": "",
392                "closeOnTrigger": false, "placeType": "", "smpType": "None", "smpGroup": 0,
393                "smpOrderId": "", "feeCurrency": ""
394            }}"#
395        );
396        serde_json::from_str(&json).unwrap()
397    }
398
399    fn order(order_id: &str, status: &str, updated_time: Timestamp) -> Order {
400        order_msg(order_id, status, updated_time).into()
401    }
402
403    fn position_msg(idx: u8, size: &str, updated_time: Timestamp, seq: i64) -> PositionMsg {
404        let json = format!(
405            r#"{{
406                "category": "linear", "symbol": "BTCUSDT", "positionIdx": {idx},
407                "riskId": 1, "riskLimitValue": "200000", "side": "Buy", "size": "{size}",
408                "entryPrice": "100", "sessionAvgPrice": "", "leverage": "10",
409                "positionValue": "100", "markPrice": "100", "positionIM": "10",
410                "positionMM": "1", "positionIMByMp": "10", "positionMMByMp": "1",
411                "takeProfit": "0", "stopLoss": "0", "trailingStop": "0",
412                "unrealisedPnl": "5", "cumRealisedPnl": "0", "curRealisedPnl": "0",
413                "createdTime": "1000", "updatedTime": "{updated_time}", "liqPrice": "",
414                "positionStatus": "Normal", "adlRankIndicator": 0, "autoAddMargin": 0,
415                "leverageSysUpdatedTime": "", "mmrSysUpdatedTime": "", "seq": {seq},
416                "isReduceOnly": false
417            }}"#
418        );
419        serde_json::from_str(&json).unwrap()
420    }
421
422    fn topic_message(json: &str) -> TopicMessage {
423        match IncomingMessage::from_json(json).unwrap() {
424            IncomingMessage::Topic(msg) => msg,
425            other => panic!("not a private topic message: {other:?}"),
426        }
427    }
428
429    #[test]
430    fn user_state_is_send_and_sync() {
431        fn assert_send_sync<T: Send + Sync>() {}
432        assert_send_sync::<UserState>();
433    }
434
435    #[test]
436    fn order_lifecycle_open_update_close() {
437        let mut state = UserState::new();
438
439        let opened = state.apply_order(order_msg("1", "New", 1));
440        let updated = state.apply_order(order_msg("1", "PartiallyFilled", 2));
441        let closed = state.apply_order(order_msg("1", "Filled", 3));
442
443        assert!(matches!(opened, Some(Change::OrderOpened(_))));
444        assert!(matches!(updated, Some(Change::OrderUpdated(_))));
445        assert!(matches!(
446            closed,
447            Some(Change::OrderClosed { ref order, .. }) if order.order_status == OrderStatus::Filled
448        ));
449        assert_eq!(state.orders().count(), 0);
450    }
451
452    #[test]
453    fn open_order_is_queryable() {
454        let mut state = UserState::new();
455        state.apply_order(order_msg("1", "New", 1));
456
457        assert!(state.order(LINEAR, "1").is_some());
458        assert!(state.order(Category::Spot, "1").is_none());
459        assert!(state.order_by_link_id(LINEAR, "link-1").is_some());
460        assert_eq!(state.orders_for(LINEAR, "BTCUSDT").count(), 1);
461        // leavesQty is derived when the stream omits it.
462        assert_eq!(
463            state.order(LINEAR, "1").unwrap().leaves_qty,
464            Decimal::from(2)
465        );
466    }
467
468    #[test]
469    fn stale_order_update_is_ignored() {
470        let mut state = UserState::new();
471        state.apply_order(order_msg("1", "PartiallyFilled", 5));
472
473        let change = state.apply_order(order_msg("1", "New", 4));
474
475        assert!(change.is_none());
476        assert_eq!(
477            state.order(LINEAR, "1").unwrap().order_status,
478            OrderStatus::PartiallyFilled
479        );
480    }
481
482    #[test]
483    fn late_update_after_close_does_not_reopen_the_order() {
484        let mut state = UserState::new();
485        state.apply_order(order_msg("1", "New", 1));
486        state.apply_order(order_msg("1", "Filled", 3));
487
488        let change = state.apply_order(order_msg("1", "PartiallyFilled", 2));
489
490        assert!(change.is_none());
491        assert!(state.order(LINEAR, "1").is_none());
492    }
493
494    #[test]
495    fn order_closed_before_being_seen_open_is_reported_but_not_stored() {
496        let mut state = UserState::new();
497
498        let change = state.apply_order(order_msg("1", "Cancelled", 1));
499
500        assert!(matches!(change, Some(Change::OrderClosed { .. })));
501        assert!(state.order(LINEAR, "1").is_none());
502    }
503
504    #[test]
505    fn triggered_conditional_order_continues_under_the_same_id() {
506        let mut state = UserState::new();
507        state.apply_order(order_msg("1", "Untriggered", 1));
508        state.apply_order(order_msg("1", "Triggered", 2));
509
510        let change = state.apply_order(order_msg("1", "New", 3));
511
512        assert!(matches!(change, Some(Change::OrderOpened(_))));
513        assert!(state.order(LINEAR, "1").is_some());
514    }
515
516    #[test]
517    fn snapshot_does_not_resurrect_an_order_closed_by_the_stream() {
518        let mut state = UserState::new();
519        state.apply_order(order_msg("1", "Filled", 5));
520
521        // The snapshot was taken before the fill.
522        state.set_orders(LINEAR, vec![order("1", "New", 4)], 4);
523
524        assert!(state.order(LINEAR, "1").is_none());
525    }
526
527    #[test]
528    fn snapshot_does_not_overwrite_newer_stream_data() {
529        let mut state = UserState::new();
530        state.apply_order(order_msg("1", "PartiallyFilled", 5));
531
532        state.set_orders(LINEAR, vec![order("1", "New", 4)], 4);
533
534        assert_eq!(
535            state.order(LINEAR, "1").unwrap().order_status,
536            OrderStatus::PartiallyFilled
537        );
538    }
539
540    #[test]
541    fn snapshot_removes_missing_orders_but_keeps_orders_opened_after_it() {
542        let mut state = UserState::new();
543        state.apply_order(order_msg("old", "New", 1));
544        state.apply_order(order_msg("new", "New", 20));
545        state.apply_order(order_msg("spot-is-untouched", "New", 1));
546
547        state.set_orders(LINEAR, vec![order("snap", "New", 5)], 10);
548
549        assert!(state.order(LINEAR, "old").is_none());
550        assert!(state.order(LINEAR, "new").is_some());
551        assert!(state.order(LINEAR, "snap").is_some());
552    }
553
554    #[test]
555    fn snapshot_skips_closed_orders() {
556        let mut state = UserState::new();
557
558        state.set_orders(LINEAR, vec![order("1", "Filled", 5)], 10);
559
560        assert_eq!(state.orders().count(), 0);
561    }
562
563    #[test]
564    fn hedge_mode_positions_are_separate() {
565        let mut state = UserState::new();
566
567        state.apply_position(position_msg(1, "1", 1, 1));
568        state.apply_position(position_msg(2, "3", 1, 2));
569
570        let buy = state.position(LINEAR, "BTCUSDT", PositionIdx::Buy).unwrap();
571        let sell = state
572            .position(LINEAR, "BTCUSDT", PositionIdx::Sell)
573            .unwrap();
574        assert_eq!(buy.size, Decimal::from(1));
575        assert_eq!(sell.size, Decimal::from(3));
576        // Per-position margin and PnL come from the position message itself.
577        assert_eq!(buy.position_im, Some(Decimal::from(10)));
578        assert_eq!(buy.position_mm, Some(Decimal::from(1)));
579        assert_eq!(buy.unrealised_pnl, Some(Decimal::from(5)));
580    }
581
582    #[test]
583    fn stale_position_update_is_ignored() {
584        let mut state = UserState::new();
585        state.apply_position(position_msg(0, "2", 5, 10));
586
587        assert!(state.apply_position(position_msg(0, "1", 4, 11)).is_none());
588        // Same time: the trade sequence decides.
589        assert!(state.apply_position(position_msg(0, "1", 5, 9)).is_none());
590        assert!(state.apply_position(position_msg(0, "3", 5, 12)).is_some());
591
592        let position = state
593            .position(LINEAR, "BTCUSDT", PositionIdx::OneWay)
594            .unwrap();
595        assert_eq!(position.size, Decimal::from(3));
596    }
597
598    #[test]
599    fn closed_position_is_kept_but_not_open() {
600        let mut state = UserState::new();
601        state.apply_position(position_msg(0, "2", 1, 1));
602
603        state.apply_position(position_msg(0, "0", 2, 2));
604
605        assert_eq!(state.positions().count(), 1);
606        assert_eq!(state.open_positions().count(), 0);
607    }
608
609    #[test]
610    fn position_snapshot_merges_by_updated_time() {
611        let mut state = UserState::new();
612        state.apply_position(position_msg(1, "5", 20, 1));
613        state.apply_position(position_msg(2, "5", 1, 1));
614
615        state.set_positions(
616            LINEAR,
617            vec![Position::from(position_msg(1, "1", 10, 1))],
618            10,
619        );
620
621        // Buy side: the stream version is newer than the snapshot entry.
622        let buy = state.position(LINEAR, "BTCUSDT", PositionIdx::Buy).unwrap();
623        assert_eq!(buy.size, Decimal::from(5));
624        // Sell side: missing from the snapshot and older than it.
625        assert!(
626            state
627                .position(LINEAR, "BTCUSDT", PositionIdx::Sell)
628                .is_none()
629        );
630    }
631
632    #[test]
633    fn apply_dispatches_order_stream_messages() {
634        let json = r#"{
635            "id": "5923240c6880ab-c59f-420b-9adb-3639adc9dd90",
636            "topic": "order",
637            "creationTime": 1672364262474,
638            "data": [
639                {
640                    "symbol": "ETH-30DEC22-1400-C",
641                    "orderId": "5cf98598-39a7-459e-97bf-76ca765ee020",
642                    "side": "Sell",
643                    "orderType": "Market",
644                    "cancelType": "UNKNOWN",
645                    "price": "72.5",
646                    "qty": "1",
647                    "orderIv": "",
648                    "timeInForce": "IOC",
649                    "orderStatus": "Filled",
650                    "orderLinkId": "",
651                    "lastPriceOnCreated": "",
652                    "reduceOnly": false,
653                    "leavesQty": "",
654                    "leavesValue": "",
655                    "cumExecQty": "1",
656                    "cumExecValue": "75",
657                    "avgPrice": "75",
658                    "blockTradeId": "",
659                    "positionIdx": 0,
660                    "cumExecFee": "0.358635",
661                    "closedPnl": "0",
662                    "createdTime": "1672364262444",
663                    "updatedTime": "1672364262457",
664                    "rejectReason": "EC_NoError",
665                    "stopOrderType": "",
666                    "tpslMode": "",
667                    "triggerPrice": "",
668                    "takeProfit": "",
669                    "stopLoss": "",
670                    "tpTriggerBy": "",
671                    "slTriggerBy": "",
672                    "tpLimitPrice": "",
673                    "slLimitPrice": "",
674                    "triggerDirection": 0,
675                    "triggerBy": "",
676                    "closeOnTrigger": false,
677                    "category": "option",
678                    "placeType": "price",
679                    "smpType": "None",
680                    "smpGroup": 0,
681                    "smpOrderId": "",
682                    "feeCurrency": "",
683                    "cumFeeDetail": {
684                        "MNT": "0.00242968"
685                    }
686                }
687            ]
688        }"#;
689        let mut state = UserState::new();
690
691        let changes = state.apply(topic_message(json));
692
693        // An IOC option order filled at once: reported as closed, not stored.
694        assert!(matches!(
695            changes.as_slice(),
696            [Change::OrderClosed { key, .. }] if key.category == Category::Option
697        ));
698        assert_eq!(state.orders().count(), 0);
699    }
700
701    #[test]
702    fn apply_dispatches_wallet_stream_messages() {
703        let json = r#"{
704            "id": "592324d2bce751-ad38-48eb-8f42-4671d1fb4d4e",
705            "topic": "wallet",
706            "creationTime": 1700034722104,
707            "data": [
708                {
709                    "accountIMRate": "0",
710                    "accountIMRateByMp": "0",
711                    "accountMMRate": "0",
712                    "accountMMRateByMp": "0",
713                    "totalEquity": "10262.91335023",
714                    "totalWalletBalance": "9684.46297164",
715                    "totalMarginBalance": "9684.46297164",
716                    "totalAvailableBalance": "9556.6056555",
717                    "totalPerpUPL": "0",
718                    "totalInitialMargin": "0",
719                    "totalInitialMarginByMp": "0",
720                    "totalMaintenanceMargin": "0",
721                    "totalMaintenanceMarginByMp": "0",
722                    "coin": [
723                        {
724                            "coin": "BTC",
725                            "equity": "0.00102964",
726                            "usdValue": "36.70759517",
727                            "walletBalance": "0.00102964",
728                            "availableToWithdraw": "0.00102964",
729                            "availableToBorrow": "",
730                            "borrowAmount": "0",
731                            "accruedInterest": "0",
732                            "totalOrderIM": "",
733                            "totalPositionIM": "",
734                            "totalPositionMM": "",
735                            "unrealisedPnl": "0",
736                            "cumRealisedPnl": "-0.00000973",
737                            "bonus": "0",
738                            "collateralSwitch": true,
739                            "marginCollateral": true,
740                            "locked": "0",
741                            "spotHedgingQty": "0.01592413",
742                            "spotBorrow": "0"
743                        }
744                    ],
745                    "accountLTV": "0",
746                    "accountType": "UNIFIED"
747                }
748            ]
749        }"#;
750        let mut state = UserState::new();
751
752        let changes = state.apply(topic_message(json));
753
754        assert!(matches!(
755            changes.as_slice(),
756            [Change::WalletUpdated(AccountType::UNIFIED)]
757        ));
758        assert!(state.wallet(AccountType::UNIFIED).is_some());
759    }
760}