1use std::collections::{HashMap, HashSet, VecDeque};
26
27use crate::{
28 AccountType, Category, Error, PositionIdx, Timestamp,
29 http::{
30 Client, GetOpenClosedOrdersParams, GetPositionInfoParams, GetWalletBalanceParams, Order,
31 Position, WalletBalance,
32 },
33 ws::{OrderMsg, PositionMsg, TopicMessage, WalletMsg},
34};
35
36#[derive(Debug, Clone, Default)]
48pub struct AccountScope {
49 categories: Vec<(Category, Option<String>)>,
50 wallet: Option<AccountType>,
51}
52
53impl AccountScope {
54 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn linear(self, settle_coin: impl Into<String>) -> Self {
62 self.category(Category::Linear, Some(settle_coin.into()))
63 }
64
65 pub fn inverse(self) -> Self {
67 self.category(Category::Inverse, None)
68 }
69
70 pub fn spot(self) -> Self {
72 self.category(Category::Spot, None)
73 }
74
75 pub fn option(self) -> Self {
77 self.category(Category::Option, None)
78 }
79
80 pub fn category(mut self, category: Category, settle_coin: Option<String>) -> Self {
82 self.categories.push((category, settle_coin));
83 self
84 }
85
86 pub fn wallet(mut self, account_type: AccountType) -> Self {
88 self.wallet = Some(account_type);
89 self
90 }
91}
92
93pub const MAX_CLOSED_ORDERS: usize = 10_000;
96
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
99pub struct OrderKey {
100 pub category: Category,
102 pub order_id: String,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Hash)]
109pub struct PositionKey {
110 pub category: Category,
112 pub symbol: String,
114 pub position_idx: PositionIdx,
116}
117
118#[derive(Debug)]
121pub enum Change {
122 OrderOpened(OrderKey),
124 OrderUpdated(OrderKey),
126 OrderClosed {
129 key: OrderKey,
131 order: Box<Order>,
133 },
134 PositionUpdated(PositionKey),
136 WalletUpdated(AccountType),
138}
139
140#[derive(Debug, Default)]
144pub struct AccountState {
145 wallets: HashMap<AccountType, WalletBalance>,
146 orders: HashMap<OrderKey, Order>,
147 closed_orders: ClosedOrders,
148 positions: HashMap<PositionKey, Position>,
149}
150
151impl AccountState {
152 pub fn new() -> Self {
154 Self::default()
155 }
156
157 pub async fn load(client: &Client, scope: &AccountScope) -> Result<Self, Error> {
161 let mut state = Self::new();
162 state.reload(client, scope).await?;
163 Ok(state)
164 }
165
166 pub async fn reload(&mut self, client: &Client, scope: &AccountScope) -> Result<(), Error> {
176 let snapshot_time = client.server_timestamp();
177
178 for (category, settle_coin) in &scope.categories {
179 let mut params = GetOpenClosedOrdersParams::new(*category);
180 if let Some(coin) = settle_coin {
181 params = params.with_settle_coin(coin.clone());
182 }
183 let orders = client.get_open_closed_orders_all(¶ms).await?;
184 self.set_orders(*category, orders, snapshot_time);
185
186 if *category != Category::Spot {
187 let mut params = GetPositionInfoParams::new(*category);
188 if let Some(coin) = settle_coin {
189 params = params.with_settle_coin(coin.clone());
190 }
191 let positions = client.get_position_info_all(¶ms).await?;
192 self.set_positions(*category, positions, snapshot_time);
193 }
194 }
195
196 if let Some(account_type) = scope.wallet {
197 let params = GetWalletBalanceParams::new(account_type);
198 for balance in client.get_wallet_balance(¶ms).await?.result.list {
199 self.set_wallet(balance);
200 }
201 }
202 Ok(())
203 }
204
205 pub fn set_wallet(&mut self, balance: WalletBalance) {
207 self.wallets.insert(balance.account_type, balance);
208 }
209
210 pub fn set_orders(&mut self, category: Category, orders: Vec<Order>, snapshot_time: Timestamp) {
219 let mut in_snapshot = HashSet::with_capacity(orders.len());
220 for order in orders {
221 if !order.is_open_status() {
222 continue;
223 }
224 let key = OrderKey {
225 category,
226 order_id: order.order_id.clone(),
227 };
228 in_snapshot.insert(key.clone());
229 if self.closed_orders.closed_since(&key, order.updated_time) {
230 continue;
231 }
232 match self.orders.get(&key) {
233 Some(stored) if stored.updated_time > order.updated_time => {}
234 _ => {
235 self.orders.insert(key, order);
236 }
237 }
238 }
239
240 self.orders.retain(|key, order| {
241 key.category != category
242 || in_snapshot.contains(key)
243 || order.updated_time > snapshot_time
244 });
245 }
246
247 pub fn set_positions(
253 &mut self,
254 category: Category,
255 positions: Vec<Position>,
256 snapshot_time: Timestamp,
257 ) {
258 let mut in_snapshot = HashSet::with_capacity(positions.len());
259 for position in positions {
260 let key = PositionKey {
261 category,
262 symbol: position.symbol.clone(),
263 position_idx: position.position_idx,
264 };
265 in_snapshot.insert(key.clone());
266 match self.positions.get(&key) {
267 Some(stored) if is_newer_position(stored, &position) => {}
268 _ => {
269 self.positions.insert(key, position);
270 }
271 }
272 }
273
274 self.positions.retain(|key, position| {
275 key.category != category
276 || in_snapshot.contains(key)
277 || position.updated_time > snapshot_time
278 });
279 }
280
281 pub fn apply(&mut self, msg: TopicMessage) -> Vec<Change> {
286 match msg {
287 TopicMessage::Order(msg) => msg
288 .data
289 .into_iter()
290 .filter_map(|order| self.apply_order(order))
291 .collect(),
292 TopicMessage::Position(msg) => msg
293 .data
294 .into_iter()
295 .filter_map(|position| self.apply_position(position))
296 .collect(),
297 TopicMessage::Wallet(msg) => msg
298 .data
299 .into_iter()
300 .map(|wallet| self.apply_wallet(wallet))
301 .collect(),
302 TopicMessage::Execution(_)
303 | TopicMessage::FastExecution(_)
304 | TopicMessage::Greeks(_)
305 | TopicMessage::Dcp(_) => Vec::new(),
306 }
307 }
308
309 pub fn apply_order(&mut self, msg: OrderMsg) -> Option<Change> {
311 let key = OrderKey {
312 category: msg.category,
313 order_id: msg.order_id.clone(),
314 };
315 if self.closed_orders.closed_since(&key, msg.updated_time) {
316 return None;
317 }
318
319 let open = msg.order_status.is_open();
320 match self.orders.get_mut(&key) {
321 Some(stored) if stored.updated_time > msg.updated_time => None,
322 Some(stored) if open => {
323 stored.update(msg);
324 Some(Change::OrderUpdated(key))
325 }
326 Some(_) => {
327 let mut order = self.orders.remove(&key)?;
328 let updated_time = msg.updated_time;
329 order.update(msg);
330 self.closed_orders.insert(key.clone(), updated_time);
331 Some(Change::OrderClosed {
332 key,
333 order: Box::new(order),
334 })
335 }
336 None if open => {
337 self.orders.insert(key.clone(), msg.into());
338 Some(Change::OrderOpened(key))
339 }
340 None => {
341 self.closed_orders.insert(key.clone(), msg.updated_time);
343 Some(Change::OrderClosed {
344 key,
345 order: Box::new(msg.into()),
346 })
347 }
348 }
349 }
350
351 pub fn apply_position(&mut self, msg: PositionMsg) -> Option<Change> {
353 let key = PositionKey {
354 category: msg.category,
355 symbol: msg.symbol.clone(),
356 position_idx: msg.position_idx,
357 };
358 let position = Position::from(msg);
359 if let Some(stored) = self.positions.get(&key)
360 && is_newer_position(stored, &position)
361 {
362 return None;
363 }
364 self.positions.insert(key.clone(), position);
365 Some(Change::PositionUpdated(key))
366 }
367
368 pub fn apply_wallet(&mut self, msg: WalletMsg) -> Change {
370 let account_type = msg.account_type;
371 self.wallets.insert(account_type, msg.into());
372 Change::WalletUpdated(account_type)
373 }
374
375 pub fn wallet(&self, account_type: AccountType) -> Option<&WalletBalance> {
379 self.wallets.get(&account_type)
380 }
381
382 pub fn wallets(&self) -> impl Iterator<Item = &WalletBalance> {
384 self.wallets.values()
385 }
386
387 pub fn order(&self, category: Category, order_id: &str) -> Option<&Order> {
389 self.orders.get(&OrderKey {
390 category,
391 order_id: order_id.to_owned(),
392 })
393 }
394
395 pub fn order_by_link_id(&self, category: Category, order_link_id: &str) -> Option<&Order> {
397 self.orders.iter().find_map(|(key, order)| {
398 (key.category == category && order.order_link_id.as_deref() == Some(order_link_id))
399 .then_some(order)
400 })
401 }
402
403 pub fn orders(&self) -> impl Iterator<Item = (&OrderKey, &Order)> {
405 self.orders.iter()
406 }
407
408 pub fn orders_for<'a>(
410 &'a self,
411 category: Category,
412 symbol: &'a str,
413 ) -> impl Iterator<Item = &'a Order> {
414 self.orders
415 .iter()
416 .filter(move |(key, order)| key.category == category && order.symbol == symbol)
417 .map(|(_, order)| order)
418 }
419
420 pub fn position(
422 &self,
423 category: Category,
424 symbol: &str,
425 position_idx: PositionIdx,
426 ) -> Option<&Position> {
427 self.positions.get(&PositionKey {
428 category,
429 symbol: symbol.to_owned(),
430 position_idx,
431 })
432 }
433
434 pub fn positions(&self) -> impl Iterator<Item = (&PositionKey, &Position)> {
437 self.positions.iter()
438 }
439
440 pub fn open_positions(&self) -> impl Iterator<Item = (&PositionKey, &Position)> {
442 self.positions
443 .iter()
444 .filter(|(_, position)| !position.size.is_zero())
445 }
446}
447
448fn is_newer_position(stored: &Position, incoming: &Position) -> bool {
453 if stored.updated_time != incoming.updated_time {
454 return stored.updated_time > incoming.updated_time;
455 }
456 stored.seq >= 0 && incoming.seq >= 0 && stored.seq > incoming.seq
457}
458
459#[derive(Debug, Default)]
462struct ClosedOrders {
463 by_key: HashMap<OrderKey, Timestamp>,
464 order: VecDeque<OrderKey>,
465}
466
467impl ClosedOrders {
468 fn insert(&mut self, key: OrderKey, updated_time: Timestamp) {
469 if self.by_key.insert(key.clone(), updated_time).is_none() {
470 self.order.push_back(key);
471 }
472 while self.order.len() > MAX_CLOSED_ORDERS {
473 if let Some(oldest) = self.order.pop_front() {
474 self.by_key.remove(&oldest);
475 }
476 }
477 }
478
479 fn closed_since(&self, key: &OrderKey, updated_time: Timestamp) -> bool {
486 self.by_key
487 .get(key)
488 .is_some_and(|&closed_at| closed_at >= updated_time)
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use crate::{OrderStatus, ws::IncomingMessage};
496 use rust_decimal::Decimal;
497
498 const LINEAR: Category = Category::Linear;
499
500 fn order_msg(order_id: &str, status: &str, updated_time: Timestamp) -> OrderMsg {
501 let json = format!(
502 r#"{{
503 "category": "linear", "symbol": "BTCUSDT", "orderId": "{order_id}",
504 "orderLinkId": "link-{order_id}", "side": "Buy", "orderType": "Limit",
505 "cancelType": "UNKNOWN", "price": "100", "qty": "2", "orderIv": "",
506 "timeInForce": "GTC", "orderStatus": "{status}", "lastPriceOnCreated": "",
507 "reduceOnly": false, "leavesQty": "", "leavesValue": "", "cumExecQty": "0",
508 "cumExecValue": "0", "avgPrice": "", "blockTradeId": "", "positionIdx": 0,
509 "cumExecFee": "0", "closedPnl": "0", "createdTime": "1000",
510 "updatedTime": "{updated_time}", "rejectReason": "EC_NoError",
511 "stopOrderType": "", "tpslMode": "", "triggerPrice": "", "takeProfit": "",
512 "stopLoss": "", "tpTriggerBy": "", "slTriggerBy": "", "tpLimitPrice": "",
513 "slLimitPrice": "", "triggerDirection": 0, "triggerBy": "",
514 "closeOnTrigger": false, "placeType": "", "smpType": "None", "smpGroup": 0,
515 "smpOrderId": "", "feeCurrency": ""
516 }}"#
517 );
518 serde_json::from_str(&json).unwrap()
519 }
520
521 fn order(order_id: &str, status: &str, updated_time: Timestamp) -> Order {
522 order_msg(order_id, status, updated_time).into()
523 }
524
525 fn position_msg(idx: u8, size: &str, updated_time: Timestamp, seq: i64) -> PositionMsg {
526 let json = format!(
527 r#"{{
528 "category": "linear", "symbol": "BTCUSDT", "positionIdx": {idx},
529 "riskId": 1, "riskLimitValue": "200000", "side": "Buy", "size": "{size}",
530 "entryPrice": "100", "sessionAvgPrice": "", "leverage": "10",
531 "positionValue": "100", "markPrice": "100", "positionIM": "10",
532 "positionMM": "1", "positionIMByMp": "10", "positionMMByMp": "1",
533 "takeProfit": "0", "stopLoss": "0", "trailingStop": "0",
534 "unrealisedPnl": "5", "cumRealisedPnl": "0", "curRealisedPnl": "0",
535 "createdTime": "1000", "updatedTime": "{updated_time}", "liqPrice": "",
536 "positionStatus": "Normal", "adlRankIndicator": 0, "autoAddMargin": 0,
537 "leverageSysUpdatedTime": "", "mmrSysUpdatedTime": "", "seq": {seq},
538 "isReduceOnly": false
539 }}"#
540 );
541 serde_json::from_str(&json).unwrap()
542 }
543
544 fn topic_message(json: &str) -> TopicMessage {
545 match IncomingMessage::from_json(json).unwrap() {
546 IncomingMessage::Topic(msg) => msg,
547 other => panic!("not a private topic message: {other:?}"),
548 }
549 }
550
551 #[test]
552 fn account_state_is_send_and_sync() {
553 fn assert_send_sync<T: Send + Sync>() {}
554 assert_send_sync::<AccountState>();
555 }
556
557 #[test]
558 fn order_lifecycle_open_update_close() {
559 let mut state = AccountState::new();
560
561 let opened = state.apply_order(order_msg("1", "New", 1));
562 let updated = state.apply_order(order_msg("1", "PartiallyFilled", 2));
563 let closed = state.apply_order(order_msg("1", "Filled", 3));
564
565 assert!(matches!(opened, Some(Change::OrderOpened(_))));
566 assert!(matches!(updated, Some(Change::OrderUpdated(_))));
567 assert!(matches!(
568 closed,
569 Some(Change::OrderClosed { ref order, .. }) if order.order_status == OrderStatus::Filled
570 ));
571 assert_eq!(state.orders().count(), 0);
572 }
573
574 #[test]
575 fn open_order_is_queryable() {
576 let mut state = AccountState::new();
577 state.apply_order(order_msg("1", "New", 1));
578
579 assert!(state.order(LINEAR, "1").is_some());
580 assert!(state.order(Category::Spot, "1").is_none());
581 assert!(state.order_by_link_id(LINEAR, "link-1").is_some());
582 assert_eq!(state.orders_for(LINEAR, "BTCUSDT").count(), 1);
583 assert_eq!(
585 state.order(LINEAR, "1").unwrap().leaves_qty,
586 Decimal::from(2)
587 );
588 }
589
590 #[test]
591 fn stale_order_update_is_ignored() {
592 let mut state = AccountState::new();
593 state.apply_order(order_msg("1", "PartiallyFilled", 5));
594
595 let change = state.apply_order(order_msg("1", "New", 4));
596
597 assert!(change.is_none());
598 assert_eq!(
599 state.order(LINEAR, "1").unwrap().order_status,
600 OrderStatus::PartiallyFilled
601 );
602 }
603
604 #[test]
605 fn late_update_after_close_does_not_reopen_the_order() {
606 let mut state = AccountState::new();
607 state.apply_order(order_msg("1", "New", 1));
608 state.apply_order(order_msg("1", "Filled", 3));
609
610 let change = state.apply_order(order_msg("1", "PartiallyFilled", 2));
611
612 assert!(change.is_none());
613 assert!(state.order(LINEAR, "1").is_none());
614 }
615
616 #[test]
617 fn order_closed_before_being_seen_open_is_reported_but_not_stored() {
618 let mut state = AccountState::new();
619
620 let change = state.apply_order(order_msg("1", "Cancelled", 1));
621
622 assert!(matches!(change, Some(Change::OrderClosed { .. })));
623 assert!(state.order(LINEAR, "1").is_none());
624 }
625
626 #[test]
627 fn triggered_conditional_order_continues_under_the_same_id() {
628 let mut state = AccountState::new();
629 state.apply_order(order_msg("1", "Untriggered", 1));
630 state.apply_order(order_msg("1", "Triggered", 2));
631
632 let change = state.apply_order(order_msg("1", "New", 3));
633
634 assert!(matches!(change, Some(Change::OrderOpened(_))));
635 assert!(state.order(LINEAR, "1").is_some());
636 }
637
638 #[test]
639 fn snapshot_does_not_resurrect_an_order_closed_by_the_stream() {
640 let mut state = AccountState::new();
641 state.apply_order(order_msg("1", "Filled", 5));
642
643 state.set_orders(LINEAR, vec![order("1", "New", 4)], 4);
645
646 assert!(state.order(LINEAR, "1").is_none());
647 }
648
649 #[test]
650 fn snapshot_does_not_overwrite_newer_stream_data() {
651 let mut state = AccountState::new();
652 state.apply_order(order_msg("1", "PartiallyFilled", 5));
653
654 state.set_orders(LINEAR, vec![order("1", "New", 4)], 4);
655
656 assert_eq!(
657 state.order(LINEAR, "1").unwrap().order_status,
658 OrderStatus::PartiallyFilled
659 );
660 }
661
662 #[test]
663 fn snapshot_removes_missing_orders_but_keeps_orders_opened_after_it() {
664 let mut state = AccountState::new();
665 state.apply_order(order_msg("old", "New", 1));
666 state.apply_order(order_msg("new", "New", 20));
667 state.apply_order(order_msg("spot-is-untouched", "New", 1));
668
669 state.set_orders(LINEAR, vec![order("snap", "New", 5)], 10);
670
671 assert!(state.order(LINEAR, "old").is_none());
672 assert!(state.order(LINEAR, "new").is_some());
673 assert!(state.order(LINEAR, "snap").is_some());
674 }
675
676 #[test]
677 fn snapshot_skips_closed_orders() {
678 let mut state = AccountState::new();
679
680 state.set_orders(LINEAR, vec![order("1", "Filled", 5)], 10);
681
682 assert_eq!(state.orders().count(), 0);
683 }
684
685 #[test]
686 fn hedge_mode_positions_are_separate() {
687 let mut state = AccountState::new();
688
689 state.apply_position(position_msg(1, "1", 1, 1));
690 state.apply_position(position_msg(2, "3", 1, 2));
691
692 let buy = state.position(LINEAR, "BTCUSDT", PositionIdx::Buy).unwrap();
693 let sell = state
694 .position(LINEAR, "BTCUSDT", PositionIdx::Sell)
695 .unwrap();
696 assert_eq!(buy.size, Decimal::from(1));
697 assert_eq!(sell.size, Decimal::from(3));
698 assert_eq!(buy.position_im, Some(Decimal::from(10)));
700 assert_eq!(buy.position_mm, Some(Decimal::from(1)));
701 assert_eq!(buy.unrealised_pnl, Some(Decimal::from(5)));
702 }
703
704 #[test]
705 fn stale_position_update_is_ignored() {
706 let mut state = AccountState::new();
707 state.apply_position(position_msg(0, "2", 5, 10));
708
709 assert!(state.apply_position(position_msg(0, "1", 4, 11)).is_none());
710 assert!(state.apply_position(position_msg(0, "1", 5, 9)).is_none());
712 assert!(state.apply_position(position_msg(0, "3", 5, 12)).is_some());
713
714 let position = state
715 .position(LINEAR, "BTCUSDT", PositionIdx::OneWay)
716 .unwrap();
717 assert_eq!(position.size, Decimal::from(3));
718 }
719
720 #[test]
721 fn closed_position_is_kept_but_not_open() {
722 let mut state = AccountState::new();
723 state.apply_position(position_msg(0, "2", 1, 1));
724
725 state.apply_position(position_msg(0, "0", 2, 2));
726
727 assert_eq!(state.positions().count(), 1);
728 assert_eq!(state.open_positions().count(), 0);
729 }
730
731 #[test]
732 fn position_snapshot_merges_by_updated_time() {
733 let mut state = AccountState::new();
734 state.apply_position(position_msg(1, "5", 20, 1));
735 state.apply_position(position_msg(2, "5", 1, 1));
736
737 state.set_positions(
738 LINEAR,
739 vec![Position::from(position_msg(1, "1", 10, 1))],
740 10,
741 );
742
743 let buy = state.position(LINEAR, "BTCUSDT", PositionIdx::Buy).unwrap();
745 assert_eq!(buy.size, Decimal::from(5));
746 assert!(
748 state
749 .position(LINEAR, "BTCUSDT", PositionIdx::Sell)
750 .is_none()
751 );
752 }
753
754 #[test]
755 fn apply_dispatches_order_stream_messages() {
756 let json = r#"{
757 "id": "5923240c6880ab-c59f-420b-9adb-3639adc9dd90",
758 "topic": "order",
759 "creationTime": 1672364262474,
760 "data": [
761 {
762 "symbol": "ETH-30DEC22-1400-C",
763 "orderId": "5cf98598-39a7-459e-97bf-76ca765ee020",
764 "side": "Sell",
765 "orderType": "Market",
766 "cancelType": "UNKNOWN",
767 "price": "72.5",
768 "qty": "1",
769 "orderIv": "",
770 "timeInForce": "IOC",
771 "orderStatus": "Filled",
772 "orderLinkId": "",
773 "lastPriceOnCreated": "",
774 "reduceOnly": false,
775 "leavesQty": "",
776 "leavesValue": "",
777 "cumExecQty": "1",
778 "cumExecValue": "75",
779 "avgPrice": "75",
780 "blockTradeId": "",
781 "positionIdx": 0,
782 "cumExecFee": "0.358635",
783 "closedPnl": "0",
784 "createdTime": "1672364262444",
785 "updatedTime": "1672364262457",
786 "rejectReason": "EC_NoError",
787 "stopOrderType": "",
788 "tpslMode": "",
789 "triggerPrice": "",
790 "takeProfit": "",
791 "stopLoss": "",
792 "tpTriggerBy": "",
793 "slTriggerBy": "",
794 "tpLimitPrice": "",
795 "slLimitPrice": "",
796 "triggerDirection": 0,
797 "triggerBy": "",
798 "closeOnTrigger": false,
799 "category": "option",
800 "placeType": "price",
801 "smpType": "None",
802 "smpGroup": 0,
803 "smpOrderId": "",
804 "feeCurrency": "",
805 "cumFeeDetail": {
806 "MNT": "0.00242968"
807 }
808 }
809 ]
810 }"#;
811 let mut state = AccountState::new();
812
813 let changes = state.apply(topic_message(json));
814
815 assert!(matches!(
817 changes.as_slice(),
818 [Change::OrderClosed { key, .. }] if key.category == Category::Option
819 ));
820 assert_eq!(state.orders().count(), 0);
821 }
822
823 #[test]
824 fn apply_dispatches_wallet_stream_messages() {
825 let json = r#"{
826 "id": "592324d2bce751-ad38-48eb-8f42-4671d1fb4d4e",
827 "topic": "wallet",
828 "creationTime": 1700034722104,
829 "data": [
830 {
831 "accountIMRate": "0",
832 "accountIMRateByMp": "0",
833 "accountMMRate": "0",
834 "accountMMRateByMp": "0",
835 "totalEquity": "10262.91335023",
836 "totalWalletBalance": "9684.46297164",
837 "totalMarginBalance": "9684.46297164",
838 "totalAvailableBalance": "9556.6056555",
839 "totalPerpUPL": "0",
840 "totalInitialMargin": "0",
841 "totalInitialMarginByMp": "0",
842 "totalMaintenanceMargin": "0",
843 "totalMaintenanceMarginByMp": "0",
844 "coin": [
845 {
846 "coin": "BTC",
847 "equity": "0.00102964",
848 "usdValue": "36.70759517",
849 "walletBalance": "0.00102964",
850 "availableToWithdraw": "0.00102964",
851 "availableToBorrow": "",
852 "borrowAmount": "0",
853 "accruedInterest": "0",
854 "totalOrderIM": "",
855 "totalPositionIM": "",
856 "totalPositionMM": "",
857 "unrealisedPnl": "0",
858 "cumRealisedPnl": "-0.00000973",
859 "bonus": "0",
860 "collateralSwitch": true,
861 "marginCollateral": true,
862 "locked": "0",
863 "spotHedgingQty": "0.01592413",
864 "spotBorrow": "0"
865 }
866 ],
867 "accountLTV": "0",
868 "accountType": "UNIFIED"
869 }
870 ]
871 }"#;
872 let mut state = AccountState::new();
873
874 let changes = state.apply(topic_message(json));
875
876 assert!(matches!(
877 changes.as_slice(),
878 [Change::WalletUpdated(AccountType::UNIFIED)]
879 ));
880 assert!(state.wallet(AccountType::UNIFIED).is_some());
881 }
882
883 async fn spawn_snapshot_server() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
886 use tokio::io::{AsyncReadExt, AsyncWriteExt};
887
888 const ORDERS: &str = r#"{"retCode": 0, "retMsg": "OK", "result": {"list": [{"orderId": "fd4300ae-7847-404e-b947-b46980a4d140", "orderLinkId": "test-000005", "blockTradeId": "", "symbol": "ETHUSDT", "price": "1600.00", "qty": "0.10", "side": "Buy", "isLeverage": "", "positionIdx": 1, "orderStatus": "New", "cancelType": "UNKNOWN", "rejectReason": "EC_NoError", "avgPrice": "0", "leavesQty": "0.10", "leavesValue": "160", "cumExecQty": "0.00", "cumExecValue": "0", "cumExecFee": "0", "timeInForce": "GTC", "orderType": "Limit", "stopOrderType": "UNKNOWN", "orderIv": "", "triggerPrice": "0.00", "takeProfit": "2500.00", "stopLoss": "1500.00", "tpTriggerBy": "LastPrice", "slTriggerBy": "LastPrice", "triggerDirection": 0, "triggerBy": "UNKNOWN", "lastPriceOnCreated": "", "reduceOnly": false, "closeOnTrigger": false, "smpType": "None", "smpGroup": 0, "smpOrderId": "", "tpslMode": "Full", "tpLimitPrice": "", "slLimitPrice": "", "placeType": "", "createdTime": "1684738540559", "updatedTime": "1684738540561"}], "nextPageCursor": "", "category": "linear"}, "retExtInfo": {}, "time": 1684765770483}"#;
889 const POSITIONS: &str = r#"{"retCode": 0, "retMsg": "OK", "result": {"list": [{"positionIdx": 0, "riskId": 1, "riskLimitValue": "150", "symbol": "BTCUSD", "side": "Sell", "size": "300", "avgPrice": "27464.50441675", "positionValue": "0.01092319", "tradeMode": 0, "positionStatus": "Normal", "autoAddMargin": 1, "adlRankIndicator": 2, "leverage": "10", "positionBalance": "0.00139186", "markPrice": "28224.50", "liqPrice": "", "bustPrice": "999999.00", "positionMM": "0.0000015", "positionMMByMp": "0.0000015", "positionIM": "0.00010923", "positionIMByMp": "0.00010923", "tpslMode": "Full", "takeProfit": "0.00", "stopLoss": "0.00", "trailingStop": "0.00", "unrealisedPnl": "-0.00029413", "curRealisedPnl": "0.00013123", "cumRealisedPnl": "-0.00096902", "seq": 5723621632, "isReduceOnly": false, "mmrSysUpdatedTime": "", "leverageSysUpdatedTime": "", "sessionAvgPrice": "", "createdTime": "1676538056258", "updatedTime": "1697673600012"}], "nextPageCursor": "", "category": "inverse"}, "retExtInfo": {}, "time": 1697684980172}"#;
890 const WALLET: &str = r#"{"retCode": 0, "retMsg": "OK", "result": {"list": [{"totalEquity": "3.31216591", "accountIMRate": "0", "accountIMRateByMp": "0", "totalMarginBalance": "3.00326056", "totalInitialMargin": "0", "totalInitialMarginByMp": "0", "accountType": "UNIFIED", "totalAvailableBalance": "3.00326056", "accountMMRate": "0", "accountMMRateByMp": "0", "totalPerpUPL": "0", "totalWalletBalance": "3.00326056", "accountLTV": "0", "totalMaintenanceMargin": "0", "totalMaintenanceMarginByMp": "0", "coin": [{"availableToBorrow": "3", "bonus": "0", "accruedInterest": "0", "availableToWithdraw": "0", "totalOrderIM": "0", "equity": "0", "totalPositionMM": "0", "usdValue": "0", "spotHedgingQty": "0.01592413", "unrealisedPnl": "0", "collateralSwitch": true, "borrowAmount": "0.0", "totalPositionIM": "0", "walletBalance": "0", "cumRealisedPnl": "0", "locked": "0", "marginCollateral": true, "coin": "BTC", "spotBorrow": "0"}]}]}, "retExtInfo": {}, "time": 1690872862481}"#;
891
892 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
893 let addr = listener.local_addr().unwrap();
894 let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
895 let recorded = requests.clone();
896 tokio::spawn(async move {
897 while let Ok((mut tcp, _)) = listener.accept().await {
898 let mut head = Vec::new();
899 let mut buf = [0u8; 1024];
900 while !head.windows(4).any(|w| w == b"\r\n\r\n") {
901 match tcp.read(&mut buf).await {
902 Ok(0) | Err(_) => break,
903 Ok(n) => head.extend_from_slice(&buf[..n]),
904 }
905 }
906 let head = String::from_utf8_lossy(&head).into_owned();
907 let target = head
908 .split_whitespace()
909 .nth(1)
910 .unwrap_or_default()
911 .to_owned();
912 recorded.lock().unwrap().push(target.clone());
913 let body = if target.starts_with("/v5/order/realtime") {
914 ORDERS
915 } else if target.starts_with("/v5/position/list") {
916 POSITIONS
917 } else {
918 WALLET
919 };
920 let response = format!(
921 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
922 body.len()
923 );
924 let _ = tcp.write_all(response.as_bytes()).await;
925 }
926 });
927 (format!("http://{addr}"), requests)
928 }
929
930 #[tokio::test]
931 async fn load_fetches_every_part_of_the_scope() {
932 let (url, requests) = spawn_snapshot_server().await;
933 let config = crate::http::Config::new(url).credentials("key", "secret");
934 let client = Client::new(config).unwrap();
935 let scope = AccountScope::new()
936 .linear("USDT")
937 .spot()
938 .wallet(AccountType::UNIFIED);
939
940 let state = AccountState::load(&client, &scope).await.unwrap();
941
942 assert_eq!(state.orders().count(), 2);
944 let order_id = "fd4300ae-7847-404e-b947-b46980a4d140";
945 assert!(state.order(Category::Linear, order_id).is_some());
946 assert!(state.order(Category::Spot, order_id).is_some());
947 assert_eq!(state.open_positions().count(), 1);
949 assert!(state.wallet(AccountType::UNIFIED).is_some());
950
951 let requests = requests.lock().unwrap().clone();
952 assert_eq!(requests.len(), 4, "{requests:?}");
953 assert!(requests[0].starts_with("/v5/order/realtime"));
954 assert!(requests[0].contains("category=linear") && requests[0].contains("settleCoin=USDT"));
955 assert!(requests[1].starts_with("/v5/position/list"));
956 assert!(requests[1].contains("settleCoin=USDT"));
957 assert!(requests[2].starts_with("/v5/order/realtime"));
958 assert!(requests[2].contains("category=spot"));
959 assert!(requests[3].starts_with("/v5/account/wallet-balance"));
960 }
961}