1use 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
33pub const MAX_CLOSED_ORDERS: usize = 10_000;
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct OrderKey {
40 pub category: Category,
41 pub order_id: String,
42}
43
44#[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#[derive(Debug)]
56pub enum Change {
57 OrderOpened(OrderKey),
59 OrderUpdated(OrderKey),
61 OrderClosed { key: OrderKey, order: Box<Order> },
64 PositionUpdated(PositionKey),
66 WalletUpdated(AccountType),
68}
69
70#[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 pub fn set_wallet(&mut self, balance: WalletBalance) {
90 self.wallets.insert(balance.account_type, balance);
91 }
92
93 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 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 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 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 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 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 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 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 pub fn orders(&self) -> impl Iterator<Item = (&OrderKey, &Order)> {
284 self.orders.iter()
285 }
286
287 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 pub fn positions(&self) -> impl Iterator<Item = (&PositionKey, &Position)> {
315 self.positions.iter()
316 }
317
318 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
326fn 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#[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 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 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 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 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 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 let buy = state.position(LINEAR, "BTCUSDT", PositionIdx::Buy).unwrap();
623 assert_eq!(buy.size, Decimal::from(5));
624 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 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}