Skip to main content

bybit/ws/
incoming_message.rs

1use std::{borrow::Cow, collections::HashMap};
2
3use crate::{
4    AccountType, AdlRankIndicator, CancelType, Category, CreateType, ExecType, ExtraFeeType,
5    ExtraSubFeeType, Interval, OcoTriggerBy, OrderStatus, OrderType, PlaceType, PositionIdx,
6    PositionStatus, RejectReason, Side, SlippageToleranceType, SmpType, StopOrderType,
7    TickDirection, TimeInForce, Timestamp, Topic, TpslMode, TriggerBy, TriggerDirection,
8    http::{OrderbookLevel, WalletCoin},
9    serde::{deserialize_json, hash_map},
10    serde::{empty_string_as_none, int_to_bool, string_to_bool, string_to_option_bool},
11};
12
13use rust_decimal::{Decimal, serde::str_option::deserialize as option_decimal};
14use serde::Deserialize;
15use serde_aux::prelude::{
16    deserialize_number_from_string as number,
17    deserialize_option_number_from_string as option_number,
18};
19
20#[derive(PartialEq, Deserialize, Debug)]
21#[serde(untagged)]
22pub enum IncomingMessage {
23    Command(CommandMsg),
24    // TickerMsg is 584 bytes (TickerDeltaMsg alone is 528 bytes — 24 optional Decimals × 16 bytes
25    // each, plus TickerSnapshotMsg at 448 bytes). Without Box the entire IncomingMessage enum
26    // would be 584 bytes on every allocation, including the tiny Command/Trade/Topic variants that
27    // flow through the mpsc channel far more frequently. Box keeps IncomingMessage at 104 bytes.
28    Ticker(Box<TickerMsg>),
29    Trade(TradeMsg),
30    KLine(KLineMsg),
31    Orderbook(OrderbookMsg),
32    AllLiquidation(AllLiquidationMsg),
33    Topic(TopicMessage),
34}
35
36/// The fields needed to pick the message type; everything else is skipped.
37#[derive(Deserialize)]
38struct Envelope<'a> {
39    #[serde(borrow, default)]
40    op: Option<Cow<'a, str>>,
41    #[serde(borrow, default)]
42    topic: Option<Cow<'a, str>>,
43}
44
45impl IncomingMessage {
46    /// Parse a WebSocket text frame.
47    ///
48    /// Prefer this over the derived `Deserialize` impl (which is
49    /// `#[serde(untagged)]` and tries every variant in turn): the `op`/`topic`
50    /// fields are read first and only the matching type is deserialized, so
51    /// errors point at the offending field (e.g. `data.b[0]`) and topics with
52    /// a category suffix such as `order.linear` are recognized.
53    pub fn from_json(json: &str) -> Result<Self, serde_path_to_error::Error<serde_json::Error>> {
54        let envelope: Envelope = deserialize_json(json)?;
55
56        if envelope.op.is_some() {
57            return deserialize_json(json).map(Self::Command);
58        }
59        let Some(topic) = envelope.topic else {
60            return deserialize_json(json);
61        };
62        let (kind, rest) = topic.split_once('.').unwrap_or((&topic, ""));
63
64        match kind {
65            "tickers" => deserialize_json(json).map(|msg| Self::Ticker(Box::new(msg))),
66            "publicTrade" => deserialize_json(json).map(Self::Trade),
67            "kline" => deserialize_json(json).map(Self::KLine),
68            "orderbook" => deserialize_json(json).map(Self::Orderbook),
69            "allLiquidation" => deserialize_json(json).map(Self::AllLiquidation),
70            "order" => deserialize_json(json)
71                .map(TopicMessage::Order)
72                .map(Self::Topic),
73            "position" => deserialize_json(json)
74                .map(TopicMessage::Position)
75                .map(Self::Topic),
76            "wallet" => deserialize_json(json)
77                .map(TopicMessage::Wallet)
78                .map(Self::Topic),
79            "execution" if rest == "fast" || rest.starts_with("fast.") => deserialize_json(json)
80                .map(TopicMessage::FastExecution)
81                .map(Self::Topic),
82            "execution" => deserialize_json(json)
83                .map(TopicMessage::Execution)
84                .map(Self::Topic),
85            "fastExecution" => deserialize_json(json)
86                .map(TopicMessage::FastExecution)
87                .map(Self::Topic),
88            "greeks" => deserialize_json(json)
89                .map(TopicMessage::Greeks)
90                .map(Self::Topic),
91            "dcp" => deserialize_json(json)
92                .map(TopicMessage::Dcp)
93                .map(Self::Topic),
94            // Unknown topic: let the untagged impl produce the error.
95            _ => deserialize_json(json),
96        }
97    }
98
99    pub fn is_pong(&self) -> bool {
100        matches!(
101            self,
102            IncomingMessage::Command(CommandMsg::Pong {
103                req_id: _,
104                ret_msg: _,
105                conn_id: _,
106                args: _,
107                success: _,
108            })
109        )
110    }
111    pub fn is_ping(&self) -> bool {
112        matches!(
113            self,
114            IncomingMessage::Command(CommandMsg::Ping {
115                req_id: _,
116                ret_msg: _,
117                conn_id: _,
118                args: _,
119                success: _,
120            })
121        )
122    }
123}
124
125#[derive(PartialEq, Deserialize, Debug)]
126#[serde(tag = "op")]
127pub enum CommandMsg {
128    #[serde(rename = "subscribe")]
129    Subscribe {
130        #[serde(default, deserialize_with = "empty_string_as_none")]
131        req_id: Option<String>,
132        #[serde(default, deserialize_with = "empty_string_as_none")]
133        ret_msg: Option<String>,
134        conn_id: String,
135        success: bool,
136    },
137    #[serde(rename = "unsubscribe")]
138    Unsubscribe {
139        #[serde(default, deserialize_with = "empty_string_as_none")]
140        req_id: Option<String>,
141        #[serde(default, deserialize_with = "empty_string_as_none")]
142        ret_msg: Option<String>,
143        conn_id: String,
144        success: bool,
145    },
146    #[serde(rename = "auth")]
147    Auth {
148        #[serde(default, deserialize_with = "empty_string_as_none")]
149        req_id: Option<String>,
150        #[serde(default, deserialize_with = "empty_string_as_none")]
151        ret_msg: Option<String>,
152        conn_id: String,
153        success: bool,
154    },
155    #[serde(rename = "pong")]
156    Pong {
157        #[serde(default, deserialize_with = "empty_string_as_none")]
158        req_id: Option<String>,
159        #[serde(default, deserialize_with = "empty_string_as_none")]
160        ret_msg: Option<String>,
161        conn_id: String,
162        args: Option<Vec<String>>,
163        success: Option<bool>,
164    },
165    #[serde(rename = "ping")]
166    Ping {
167        #[serde(default, deserialize_with = "empty_string_as_none")]
168        req_id: Option<String>,
169        #[serde(default, deserialize_with = "empty_string_as_none")]
170        ret_msg: Option<String>,
171        conn_id: String,
172        args: Option<Vec<String>>,
173        success: bool,
174    },
175}
176
177// TODO: Use PublicMsg<T>
178#[derive(PartialEq, Deserialize, Debug)]
179#[serde(tag = "type")]
180pub enum TickerMsg {
181    #[serde(rename = "snapshot")]
182    Snapshot {
183        topic: Topic,
184        #[serde(default, deserialize_with = "option_number")]
185        cs: Option<u64>,
186        ts: Timestamp,
187        data: TickerSnapshotMsg,
188    },
189    #[serde(rename = "delta")]
190    Delta {
191        topic: Topic,
192        #[serde(default, deserialize_with = "option_number")]
193        cs: Option<u64>,
194        ts: Timestamp,
195        data: TickerDeltaMsg,
196    },
197}
198
199#[derive(PartialEq, Deserialize, Debug)]
200#[serde(rename_all = "camelCase")]
201pub struct TickerSnapshotMsg {
202    pub symbol: String,
203    pub tick_direction: TickDirection,
204    pub last_price: Decimal,
205    #[serde(default, deserialize_with = "option_decimal")]
206    pub pre_open_price: Option<Decimal>,
207    #[serde(default, deserialize_with = "option_decimal")]
208    pub pre_qty: Option<Decimal>,
209    #[serde(default, deserialize_with = "empty_string_as_none")]
210    pub cur_pre_listing_phase: Option<String>,
211    pub prev_price24h: Decimal,
212    pub price24h_pcnt: Decimal,
213    pub high_price24h: Decimal,
214    pub low_price24h: Decimal,
215    pub prev_price1h: Decimal,
216    pub mark_price: Decimal,
217    pub index_price: Decimal,
218    pub open_interest: Decimal,
219    pub open_interest_value: Decimal,
220    pub turnover24h: Decimal,
221    pub volume24h: Decimal,
222    pub funding_rate: Decimal,
223    #[serde(default, deserialize_with = "number")]
224    pub next_funding_time: Timestamp,
225    pub bid1_price: Decimal,
226    pub bid1_size: Decimal,
227    pub ask1_price: Decimal,
228    pub ask1_size: Decimal,
229    #[serde(default, deserialize_with = "option_number")]
230    pub delivery_time: Option<Timestamp>,
231    #[serde(default, deserialize_with = "option_decimal")]
232    pub basis_rate: Option<Decimal>,
233    #[serde(default, deserialize_with = "option_decimal")]
234    pub delivery_fee_rate: Option<Decimal>,
235    #[serde(default, deserialize_with = "option_decimal")]
236    pub predicted_delivery_price: Option<Decimal>,
237}
238
239#[derive(PartialEq, Deserialize, Debug)]
240#[serde(rename_all = "camelCase")]
241pub struct TickerDeltaMsg {
242    pub symbol: String,
243    #[serde(default, deserialize_with = "empty_string_as_none")]
244    pub tick_direction: Option<TickDirection>,
245    #[serde(default, deserialize_with = "option_decimal")]
246    pub last_price: Option<Decimal>,
247    #[serde(default, deserialize_with = "option_decimal")]
248    pub pre_open_price: Option<Decimal>,
249    #[serde(default, deserialize_with = "option_decimal")]
250    pub pre_qty: Option<Decimal>,
251    #[serde(default, deserialize_with = "empty_string_as_none")]
252    pub cur_pre_listing_phase: Option<String>,
253    #[serde(default, deserialize_with = "option_decimal")]
254    pub prev_price24h: Option<Decimal>,
255    #[serde(default, deserialize_with = "option_decimal")]
256    pub price24h_pcnt: Option<Decimal>,
257    #[serde(default, deserialize_with = "option_decimal")]
258    pub high_price24h: Option<Decimal>,
259    #[serde(default, deserialize_with = "option_decimal")]
260    pub low_price24h: Option<Decimal>,
261    #[serde(default, deserialize_with = "option_decimal")]
262    pub prev_price1h: Option<Decimal>,
263    #[serde(default, deserialize_with = "option_decimal")]
264    pub mark_price: Option<Decimal>,
265    #[serde(default, deserialize_with = "option_decimal")]
266    pub index_price: Option<Decimal>,
267    #[serde(default, deserialize_with = "option_decimal")]
268    pub open_interest: Option<Decimal>,
269    #[serde(default, deserialize_with = "option_decimal")]
270    pub open_interest_value: Option<Decimal>,
271    #[serde(default, deserialize_with = "option_decimal")]
272    pub turnover24h: Option<Decimal>,
273    #[serde(default, deserialize_with = "option_decimal")]
274    pub volume24h: Option<Decimal>,
275    #[serde(default, deserialize_with = "option_decimal")]
276    pub funding_rate: Option<Decimal>,
277    #[serde(default, deserialize_with = "option_decimal")]
278    pub next_funding_time: Option<Decimal>,
279    #[serde(default, deserialize_with = "option_decimal")]
280    pub bid1_price: Option<Decimal>,
281    #[serde(default, deserialize_with = "option_decimal")]
282    pub bid1_size: Option<Decimal>,
283    #[serde(default, deserialize_with = "option_decimal")]
284    pub ask1_price: Option<Decimal>,
285    #[serde(default, deserialize_with = "option_decimal")]
286    pub ask1_size: Option<Decimal>,
287    #[serde(default, deserialize_with = "option_number")]
288    pub delivery_time: Option<Timestamp>,
289    #[serde(default, deserialize_with = "option_decimal")]
290    pub basis_rate: Option<Decimal>,
291    #[serde(default, deserialize_with = "option_decimal")]
292    pub delivery_fee_rate: Option<Decimal>,
293    #[serde(default, deserialize_with = "option_decimal")]
294    pub predicted_delivery_price: Option<Decimal>,
295}
296
297// TODO: Use PublicMsg<T>
298#[derive(PartialEq, Deserialize, Debug)]
299#[serde(tag = "type")]
300pub enum TradeMsg {
301    #[serde(rename = "snapshot")]
302    Snapshot {
303        #[serde(default, deserialize_with = "empty_string_as_none")]
304        id: Option<String>,
305        topic: Topic,
306        ts: Timestamp,
307        data: Vec<TradeSnapshotMsg>,
308    },
309}
310
311#[derive(PartialEq, Deserialize, Debug)]
312pub struct TradeSnapshotMsg {
313    #[serde(rename = "T")]
314    pub time: Timestamp,
315    #[serde(rename = "s")]
316    pub symbol: String,
317    #[serde(rename = "S")]
318    pub side: Side,
319    #[serde(rename = "v")]
320    pub size: Decimal,
321    #[serde(rename = "p")]
322    pub price: Decimal,
323    #[serde(rename = "L")]
324    pub tick_direction: TickDirection,
325    #[serde(rename = "i")]
326    pub trade_id: String,
327    #[serde(rename = "BT")]
328    pub block_trade: bool,
329    #[serde(rename = "RPI")]
330    pub rpi_trade: Option<bool>,
331    #[serde(rename = "mP", default, deserialize_with = "empty_string_as_none")]
332    pub mark_price: Option<String>,
333    #[serde(rename = "iP", default, deserialize_with = "empty_string_as_none")]
334    pub index_price: Option<String>,
335    #[serde(rename = "mlv", default, deserialize_with = "empty_string_as_none")]
336    pub mark_iv: Option<String>,
337    #[serde(rename = "iv", default, deserialize_with = "empty_string_as_none")]
338    pub iv: Option<String>,
339}
340
341// TODO: Use PublicMsg<T>
342#[derive(PartialEq, Deserialize, Debug)]
343#[serde(tag = "type")]
344pub enum KLineMsg {
345    #[serde(rename = "snapshot")]
346    Snapshot {
347        topic: Topic,
348        ts: Timestamp,
349        data: Vec<KLineSnapshotMsg>,
350    },
351}
352
353#[derive(PartialEq, Deserialize, Debug)]
354pub struct KLineSnapshotMsg {
355    pub start: Timestamp,
356    pub end: Timestamp,
357    pub interval: Interval,
358    pub open: Decimal,
359    pub close: Decimal,
360    pub high: Decimal,
361    pub low: Decimal,
362    pub volume: Decimal,
363    pub turnover: Decimal,
364    pub confirm: bool,
365    pub timestamp: Timestamp,
366}
367
368// TODO: Use PublicMsg<T>
369#[derive(PartialEq, Deserialize, Debug)]
370#[serde(tag = "type")]
371pub enum OrderbookMsg {
372    #[serde(rename = "snapshot")]
373    Snapshot {
374        topic: Topic,
375        ts: Timestamp,
376        data: OrderbookDataMsg,
377        cts: Timestamp,
378    },
379    #[serde(rename = "delta")]
380    Delta {
381        topic: Topic,
382        ts: Timestamp,
383        data: OrderbookDataMsg,
384        cts: Timestamp,
385    },
386}
387
388#[derive(PartialEq, Deserialize, Debug)]
389pub struct OrderbookDataMsg {
390    /// Symbol name
391    #[serde(rename = "s")]
392    pub symbol: String,
393    /// Bid, buy side. For snapshot, sorted by price in descending order.
394    /// For delta, size 0 means delete the price level
395    #[serde(rename = "b")]
396    pub bids: Vec<OrderbookLevel>,
397    /// Ask, sell side. For snapshot, sorted by price in ascending order.
398    /// For delta, size 0 means delete the price level
399    #[serde(rename = "a")]
400    pub asks: Vec<OrderbookLevel>,
401    /// Update ID, is a sequence. Occasionally, you'll receive "u"=1, which is a snapshot
402    /// data due to the restart of the websocket service. So please overwrite your local orderbook
403    #[serde(rename = "u")]
404    pub update_id: i64,
405    /// Cross sequence. You can use this field to compare different levels orderbook data,
406    /// and for the smaller seq, then it means the data is generated earlier
407    pub seq: i64,
408}
409
410// TODO: Use PublicMsg<T>
411#[derive(PartialEq, Deserialize, Debug)]
412#[serde(tag = "type")]
413pub enum AllLiquidationMsg {
414    #[serde(rename = "snapshot")]
415    Snapshot {
416        topic: Topic,
417        ts: Timestamp,
418        data: Vec<AllLiquidationSnapshotMsg>,
419    },
420}
421
422#[derive(PartialEq, Deserialize, Debug)]
423pub struct AllLiquidationSnapshotMsg {
424    #[serde(rename = "T")]
425    pub time: Timestamp,
426    #[serde(rename = "s")]
427    pub symbol: String,
428    /// When you receive a Buy update, this means that a long position has been liquidated
429    #[serde(rename = "S")]
430    pub side: Side,
431    #[serde(rename = "v")]
432    pub size: Decimal,
433    #[serde(rename = "p")]
434    pub price: Decimal,
435}
436
437#[derive(PartialEq, Deserialize, Debug)]
438#[serde(tag = "topic")] // TODO: Use field topic
439pub enum TopicMessage {
440    #[serde(rename = "order")]
441    Order(PrivateMsg<Vec<OrderMsg>>),
442    #[serde(rename = "position")]
443    Position(PrivateMsg<Vec<PositionMsg>>),
444    #[serde(rename = "wallet")]
445    Wallet(PrivateMsg<Vec<WalletMsg>>),
446    #[serde(rename = "execution")]
447    Execution(PrivateMsg<Vec<ExecutionMsg>>),
448    /// Fast execution: fills arrive immediately, before settlement completes.
449    #[serde(rename = "fastExecution")]
450    FastExecution(PrivateMsg<Vec<FastExecutionMsg>>),
451    /// Option greek updates.
452    #[serde(rename = "greeks")]
453    Greeks(PrivateMsg<Vec<GreekMsg>>),
454    /// Disconnection and Cancellation Protection status update.
455    #[serde(rename = "dcp")]
456    Dcp(PrivateMsg<DcpMsg>),
457}
458
459#[derive(PartialEq, Deserialize, Debug)]
460#[serde(rename_all = "camelCase")]
461pub struct PublicMsg<T> {
462    #[serde(default, deserialize_with = "empty_string_as_none")]
463    id: Option<String>,
464    #[serde(default, deserialize_with = "option_number")]
465    cs: Option<u64>,
466    ts: Timestamp,
467    data: T,
468}
469
470#[derive(PartialEq, Deserialize, Debug)]
471#[serde(rename_all = "camelCase")]
472pub struct PrivateMsg<T> {
473    // TODO: pub topic: Topic, /// Topic name
474    /// Message ID
475    pub id: String,
476    /// Data created timestamp (ms)
477    pub creation_time: Timestamp,
478    pub data: T,
479}
480
481#[derive(PartialEq, Deserialize, Debug, Clone)]
482#[serde(rename_all = "camelCase")]
483pub struct OrderMsg {
484    /// Product type
485    /// UTA2.0, UTA1.0: spot, linear, inverse, option
486    /// Classic account: spot, linear, inverse.
487    pub category: Category,
488    /// Order ID
489    pub order_id: String,
490    /// User customized order ID
491    #[serde(default, deserialize_with = "empty_string_as_none")]
492    pub order_link_id: Option<String>,
493    /// Whether to borrow.
494    /// Unified spot only. 0: false, 1: true.
495    /// Classic spot is not supported, always 0
496    #[serde(default, deserialize_with = "string_to_option_bool")]
497    pub is_leverage: Option<bool>,
498    /// Block trade ID
499    #[serde(default, deserialize_with = "empty_string_as_none")]
500    pub block_trade_id: Option<String>,
501    /// Symbol name
502    pub symbol: String,
503    /// Order price
504    pub price: Decimal,
505    /// Dedicated field for EU liquidity provider
506    #[serde(default, deserialize_with = "option_decimal")]
507    pub broker_order_price: Option<Decimal>,
508    /// Order qty
509    pub qty: Decimal,
510    /// Side. Buy,Sell
511    pub side: Side,
512    /// Position index. Used to identify positions in different position modes.
513    pub position_idx: PositionIdx,
514    /// Order status
515    pub order_status: OrderStatus,
516    /// Order create type
517    /// Only for category=linear or inverse
518    /// Spot, Option do not have this key
519    #[serde(default, deserialize_with = "empty_string_as_none")]
520    pub create_type: Option<CreateType>,
521    /// Cancel type
522    pub cancel_type: CancelType,
523    /// Reject reason. Classic spot is not supported
524    pub reject_reason: RejectReason,
525    /// Average filled price
526    /// returns "" for those orders without avg price, and also for those classic account orders have partilly filled but cancelled at the end
527    /// Classic Spot: not supported, always ""
528    #[serde(default, deserialize_with = "option_decimal")]
529    pub avg_price: Option<Decimal>,
530    /// The remaining qty not executed. Classic spot is not supported
531    #[serde(default, deserialize_with = "option_decimal")]
532    pub leaves_qty: Option<Decimal>,
533    /// The estimated value not executed. Classic spot is not supported
534    #[serde(default, deserialize_with = "option_decimal")]
535    pub leaves_value: Option<Decimal>,
536    /// Cumulative executed order qty
537    pub cum_exec_qty: Decimal,
538    /// Cumulative executed order value.
539    pub cum_exec_value: Decimal,
540    /// Cumulative executed trading fee.
541    /// Classic spot: it is the latest execution fee for order.
542    /// After upgraded to the Unified account, you can use execFee for each fill in Execution topic
543    pub cum_exec_fee: Decimal,
544    /// linear, spot: Cumulative trading fee details instead of cumExecFee
545    pub cum_fee_detail: Option<serde_json::Value>,
546    /// Closed profit and loss for each close position order. The figure is the same as "closedPnl" from Get Closed PnL
547    pub closed_pnl: Decimal,
548    /// Trading fee currency for Spot only. Please understand Spot trading fee currency here
549    #[serde(deserialize_with = "option_decimal")]
550    pub fee_currency: Option<Decimal>,
551    /// Time in force
552    pub time_in_force: TimeInForce,
553    /// Order type. Market,Limit. For TP/SL order, it means the order type after triggered
554    pub order_type: OrderType,
555    /// Stop order type
556    #[serde(default, deserialize_with = "empty_string_as_none")]
557    pub stop_order_type: Option<StopOrderType>,
558    /// The trigger type of Spot OCO order.OcoTriggerByUnknown, OcoTriggerByTp, OcoTriggerByBySl. Classic spot is not supported
559    #[serde(default, deserialize_with = "empty_string_as_none")]
560    pub oco_trigger_by: Option<OcoTriggerBy>,
561    /// Implied volatility
562    #[serde(deserialize_with = "option_decimal")]
563    pub order_iv: Option<Decimal>,
564    /// The unit for qty when create Spot market orders for UTA account. baseCoin, quoteCoin
565    #[serde(default, deserialize_with = "empty_string_as_none")]
566    pub market_unit: Option<String>,
567    /// Spot and Futures market order slippage tolerance type TickSize, Percent, UNKNOWN(default)
568    #[serde(default, deserialize_with = "empty_string_as_none")]
569    pub slippage_tolerance_type: Option<SlippageToleranceType>,
570    /// Slippage tolerance value
571    #[serde(default, deserialize_with = "option_decimal")]
572    pub slippage_tolerance: Option<Decimal>,
573    /// Trigger price. If stopOrderType=TrailingStop, it is activate price. Otherwise, it is trigger price
574    #[serde(deserialize_with = "option_decimal")]
575    pub trigger_price: Option<Decimal>,
576    /// Take profit price
577    #[serde(deserialize_with = "option_decimal")]
578    pub take_profit: Option<Decimal>,
579    /// Stop loss price
580    #[serde(deserialize_with = "option_decimal")]
581    pub stop_loss: Option<Decimal>,
582    /// TP/SL mode, Full: entire position for TP/SL. Partial: partial position tp/sl. Spot does not have this field, and Option returns always ""
583    #[serde(default, deserialize_with = "empty_string_as_none")]
584    pub tpsl_mode: Option<TpslMode>,
585    /// The limit order price when take profit price is triggered
586    #[serde(deserialize_with = "option_decimal")]
587    pub tp_limit_price: Option<Decimal>,
588    /// The limit order price when stop loss price is triggered
589    #[serde(deserialize_with = "option_decimal")]
590    pub sl_limit_price: Option<Decimal>,
591    /// The price type to trigger take profit
592    #[serde(default, deserialize_with = "empty_string_as_none")]
593    pub tp_trigger_by: Option<TriggerBy>,
594    /// The price type to trigger stop loss
595    #[serde(default, deserialize_with = "empty_string_as_none")]
596    pub sl_trigger_by: Option<TriggerBy>,
597    /// Trigger direction. 1: rise, 2: fall
598    pub trigger_direction: TriggerDirection,
599    /// The price type of trigger price
600    #[serde(default, deserialize_with = "empty_string_as_none")]
601    pub trigger_by: Option<TriggerBy>,
602    /// Last price when place the order, Spot is not applicable
603    #[serde(deserialize_with = "option_decimal")]
604    pub last_price_on_created: Option<Decimal>,
605    /// Reduce only. true means reduce position size
606    pub reduce_only: bool,
607    /// Close on trigger.
608    pub close_on_trigger: bool,
609    /// Place type, option used. iv, price
610    #[serde(default, deserialize_with = "empty_string_as_none")]
611    pub place_type: Option<PlaceType>,
612    /// SMP execution type
613    pub smp_type: SmpType,
614    /// Smp group ID. If the UID has no group, it is 0 by default
615    #[serde(deserialize_with = "number")]
616    pub smp_group: i64,
617    /// The counterparty's orderID which triggers this SMP execution
618    #[serde(default, deserialize_with = "empty_string_as_none")]
619    pub smp_order_id: Option<String>,
620    /// Order created timestamp (ms)
621    #[serde(deserialize_with = "number")]
622    pub created_time: Timestamp,
623    /// Order updated timestamp (ms)
624    #[serde(deserialize_with = "number")]
625    pub updated_time: Timestamp,
626}
627
628#[derive(PartialEq, Deserialize, Debug, Clone)]
629#[serde(rename_all = "camelCase")]
630pub struct PositionMsg {
631    /// Product type
632    pub category: Category,
633    /// Symbol name
634    pub symbol: String,
635    /// Position side. Buy: long, Sell: short
636    /// one-way mode: classic & UTA1.0(inverse), an empty position returns None.
637    /// UTA2.0(linear, inverse) & UTA1.0(linear): either one-way or hedge mode returns an empty string "" for an empty position.
638    #[serde(default, deserialize_with = "empty_string_as_none")]
639    pub side: Option<Side>,
640    /// Position size
641    pub size: Decimal,
642    /// Used to identify positions in different position modes
643    pub position_idx: PositionIdx,
644    /// Position value
645    pub position_value: Decimal,
646    /// Risk tier ID
647    /// for portfolio margin mode, this field returns 0, which means risk limit rules are invalid
648    #[serde(deserialize_with = "number")]
649    pub risk_id: i64,
650    /// Risk limit value
651    /// for portfolio margin mode, this field returns 0, which means risk limit rules are invalid
652    #[serde(default, deserialize_with = "option_decimal")]
653    pub risk_limit_value: Option<Decimal>,
654    /// Entry price
655    pub entry_price: Decimal,
656    /// Mark price
657    pub mark_price: Decimal,
658    /// Position leverage
659    /// for portfolio margin mode, this field returns "", which means leverage rules are invalid
660    pub leverage: Decimal,
661    /// Whether to add margin automatically. 0: false, 1: true. For UTA, it is meaningful only when UTA enables ISOLATED_MARGIN
662    #[serde(default, deserialize_with = "int_to_bool")]
663    pub auto_add_margin: bool,
664    /// Initial margin, the same value as positionIMByMp, please note this change The New Margin Calculation: Adjustments and Implications
665    /// Portfolio margin mode: returns ""
666    #[serde(rename = "positionIM", default, deserialize_with = "option_decimal")]
667    pub position_im: Option<Decimal>,
668    /// Maintenance margin, the same value as positionMMByMp
669    /// Portfolio margin mode: returns ""
670    #[serde(rename = "positionMM", default, deserialize_with = "option_decimal")]
671    pub position_mm: Option<Decimal>,
672    /// Initial margin calculated by mark price, the same value as positionIM
673    /// Portfolio margin mode: returns ""
674    #[serde(
675        rename = "positionIMByMp",
676        default,
677        deserialize_with = "option_decimal"
678    )]
679    pub position_im_by_mp: Option<Decimal>,
680    /// Maintenance margin calculated by mark price, the same value as positionMM
681    /// Portfolio margin mode: returns ""
682    #[serde(
683        rename = "positionMMByMp",
684        default,
685        deserialize_with = "option_decimal"
686    )]
687    pub position_mm_by_mp: Option<Decimal>,
688    /// Position liquidation price
689    /// Isolated margin:
690    /// it is the real price for isolated and cross positions, and keeps "" when liqPrice <= minPrice or liqPrice >= maxPrice
691    /// Cross margin:
692    /// it is an estimated price for cross positions(because the unified mode controls the risk rate according to the account), and keeps "" when liqPrice <= minPrice or liqPrice >= maxPrice
693    /// this field is empty for Portfolio Margin Mode, and no liquidation price will be provided
694    #[serde(default, deserialize_with = "option_decimal")]
695    pub liq_price: Option<Decimal>,
696    /// Take profit price
697    pub take_profit: Decimal,
698    /// Stop loss price
699    pub stop_loss: Decimal,
700    /// Trailing stop
701    pub trailing_stop: Decimal,
702    /// Unrealised profit and loss
703    pub unrealised_pnl: Decimal,
704    /// The realised PnL for the current holding position
705    pub cur_realised_pnl: Decimal,
706    /// USDC contract session avg price, it is the same figure as avg entry price shown in the web UI
707    #[serde(default, deserialize_with = "option_decimal")]
708    pub session_avg_price: Option<Decimal>,
709    /// Delta
710    #[serde(default, deserialize_with = "empty_string_as_none")]
711    pub delta: Option<String>,
712    /// Gamma
713    #[serde(default, deserialize_with = "empty_string_as_none")]
714    pub gamma: Option<String>,
715    /// Vega
716    #[serde(default, deserialize_with = "empty_string_as_none")]
717    pub vega: Option<String>,
718    /// Theta
719    #[serde(default, deserialize_with = "empty_string_as_none")]
720    pub theta: Option<String>,
721    /// Cumulative realised pnl
722    /// Futures & Perp: it is the all time cumulative realised P&L
723    /// Option: it is the realised P&L when you hold that position
724    pub cum_realised_pnl: Decimal,
725    /// Position status. Normal, Liq, Adl
726    pub position_status: PositionStatus,
727    /// Auto-deleverage rank indicator. What is Auto-Deleveraging?
728    pub adl_rank_indicator: AdlRankIndicator,
729    /// Useful when Bybit lower the risk limit
730    /// true: Only allowed to reduce the position. You can consider a series of measures, e.g., lower the risk limit, decrease leverage or reduce the position, add margin, or cancel orders, after these operations, you can call confirm new risk limit endpoint to check if your position can be removed the reduceOnly mark
731    /// false: There is no restriction, and it means your position is under the risk when the risk limit is systematically adjusted
732    /// Only meaningful for isolated margin & cross margin of USDT Perp, USDC Perp, USDC Futures, Inverse Perp and Inverse Futures, meaningless for others
733    pub is_reduce_only: bool,
734    /// Useful when Bybit lower the risk limit
735    /// When isReduceOnly=true: the timestamp (ms) when the MMR will be forcibly adjusted by the system
736    /// When isReduceOnly=false: the timestamp when the MMR had been adjusted by system
737    /// It returns the timestamp when the system operates, and if you manually operate, there is no timestamp
738    /// Keeps "" by default, if there was a lower risk limit system adjustment previously, it shows that system operation timestamp
739    /// Only meaningful for isolated margin & cross margin of USDT Perp, USDC Perp, USDC Futures, Inverse Perp and Inverse Futures, meaningless for others
740    #[serde(deserialize_with = "option_number")]
741    pub mmr_sys_updated_time: Option<Timestamp>,
742    /// Useful when Bybit lower the risk limit
743    /// When isReduceOnly=true: the timestamp (ms) when the leverage will be forcibly adjusted by the system
744    /// When isReduceOnly=false: the timestamp when the leverage had been adjusted by system
745    /// It returns the timestamp when the system operates, and if you manually operate, there is no timestamp
746    /// Keeps "" by default, if there was a lower risk limit system adjustment previously, it shows that system operation timestamp
747    /// Only meaningful for isolated margin & cross margin of USDT Perp, USDC Perp, USDC Futures, Inverse Perp and Inverse Futures, meaningless for others
748    #[serde(deserialize_with = "option_number")]
749    pub leverage_sys_updated_time: Option<Timestamp>,
750    /// Timestamp of the first time a position was created on this symbol (ms)
751    #[serde(deserialize_with = "number")]
752    pub created_time: Timestamp,
753    /// Position data updated timestamp (ms)
754    #[serde(deserialize_with = "number")]
755    pub updated_time: Timestamp,
756    /// Cross sequence, used to associate each fill and each position update
757    /// Different symbols may have the same seq, please use seq + symbol to check unique
758    /// Returns "-1" if the symbol has never been traded
759    /// Returns the seq updated by the last transaction when there are setting like leverage, risk limit
760    pub seq: i64,
761}
762
763#[derive(PartialEq, Deserialize, Debug, Clone)]
764#[serde(rename_all = "camelCase")]
765pub struct WalletMsg {
766    /// Account type.
767    /// UTA2.0: UNIFIED
768    /// UTA1.0: UNIFIED (spot/linear/options), CONTRACT(inverse)
769    /// Classic: CONTRACT, SPOT
770    pub account_type: AccountType,
771    /// Account IM rate
772    /// You can refer to this Glossary to understand the below fields calculation and mearning
773    /// All below account wide fields are not applicable to
774    /// UTA2.0(isolated margin),
775    /// UTA1.0(isolated margin), UTA1.0(CONTRACT),
776    /// classic account(SPOT, CONTRACT)
777    #[serde(rename = "accountIMRate")]
778    pub account_im_rate: Decimal,
779    /// Account MM rate
780    #[serde(rename = "accountMMRate")]
781    pub account_mm_rate: Decimal,
782    /// Account total equity (USD)
783    pub total_equity: Decimal,
784    /// Account wallet balance (USD): ∑Asset Wallet Balance By USD value of each asset
785    pub total_wallet_balance: Decimal,
786    /// Account margin balance (USD): totalWalletBalance + totalPerpUPL
787    pub total_margin_balance: Decimal,
788    /// Account available balance (USD), Cross Margin: totalMarginBalance - totalInitialMargin
789    pub total_available_balance: Decimal,
790    /// Account Perps and Futures unrealised p&l (USD): ∑Each Perp and USDC Futures upl by base coin
791    #[serde(rename = "totalPerpUPL")]
792    pub total_perp_upl: Decimal,
793    /// Account initial margin (USD): ∑Asset Total Initial Margin Base Coin
794    pub total_initial_margin: Decimal,
795    /// Account maintenance margin (USD): ∑ Asset Total Maintenance Margin Base Coin
796    pub total_maintenance_margin: Decimal,
797    /// You can ignore this field, and refer to accountIMRate, which has the same calculation
798    #[serde(rename = "accountIMRateByMp")]
799    pub account_im_rate_by_mp: Decimal,
800    /// You can ignore this field, and refer to accountMMRate, which has the same calculation
801    #[serde(rename = "accountMMRateByMp")]
802    pub account_mm_rate_by_mp: Decimal,
803    /// You can ignore this field, and refer to totalInitialMargin, which has the same calculation
804    #[serde(rename = "totalInitialMarginByMp")]
805    pub total_initial_margin_by_mp: Decimal,
806    /// You can ignore this field, and refer to totalMaintenanceMargin, which has the same calculation
807    #[serde(rename = "totalMaintenanceMarginByMp")]
808    pub total_maintenance_margin_by_mp: Decimal,
809    #[serde(deserialize_with = "hash_map")]
810    pub coin: HashMap<String, WalletCoin>,
811}
812
813#[derive(PartialEq, Deserialize, Debug, Clone)]
814#[serde(rename_all = "camelCase")]
815pub struct ExecutionMsg {
816    /// Product type spot, linear, inverse, option
817    pub category: Category,
818    /// Symbol name
819    pub symbol: String,
820    /// Whether to borrow. 0: false, 1: true
821    #[serde(default, deserialize_with = "string_to_bool")]
822    pub is_leverage: bool,
823    /// Order ID
824    pub order_id: String,
825    /// User customized order ID
826    #[serde(default, deserialize_with = "empty_string_as_none")]
827    pub order_link_id: Option<String>,
828    /// Side. Buy,Sell
829    pub side: Side,
830    /// Order price
831    pub order_price: Decimal,
832    /// Order qty
833    pub order_qty: Decimal,
834    /// The remaining qty not executed
835    pub leaves_qty: Decimal,
836    /// Order create type
837    /// Spot, Option do not have this key
838    pub create_type: CreateType,
839    /// Order type. Market,Limit
840    pub order_type: OrderType,
841    /// Stop order type. If the order is not stop order, any type is not returned
842    pub stop_order_type: StopOrderType,
843    /// Executed trading fee. You can get spot fee currency instruction here
844    pub exec_fee: Decimal,
845    /// Execution ID
846    pub exec_id: String,
847    /// Execution price
848    pub exec_price: Decimal,
849    /// Execution qty
850    pub exec_qty: Decimal,
851    /// Profit and Loss for each close position execution. The value keeps consistent with the field "cashFlow" in the Get Transaction Log
852    pub exec_pnl: Decimal,
853    /// Executed type
854    pub exec_type: ExecType,
855    /// Executed order value
856    pub exec_value: Decimal,
857    /// Executed timestamp (ms)
858    #[serde(deserialize_with = "number")]
859    pub exec_time: Timestamp,
860    /// Is maker order. true: maker, false: taker
861    pub is_maker: bool,
862    /// Trading fee rate
863    pub fee_rate: Decimal,
864    /// Implied volatility. valid for option
865    #[serde(default, deserialize_with = "option_decimal")]
866    pub trade_iv: Option<Decimal>,
867    /// Implied volatility of mark price. valid for option
868    #[serde(default, deserialize_with = "option_decimal")]
869    pub mark_iv: Option<Decimal>,
870    /// The mark price of the symbol when executing. valid for option
871    pub mark_price: Decimal,
872    /// The index price of the symbol when executing. valid for option
873    #[serde(default, deserialize_with = "option_decimal")]
874    pub index_price: Option<Decimal>,
875    /// The underlying price of the symbol when executing. valid for option
876    #[serde(default, deserialize_with = "option_decimal")]
877    pub underlying_price: Option<Decimal>,
878    /// Paradigm block trade ID
879    #[serde(default, deserialize_with = "empty_string_as_none")]
880    pub block_trade_id: Option<String>,
881    /// Closed position size
882    pub closed_size: Decimal,
883    /// Extra trading fee information. Currently, this data is returned only for kyc=Indian user or spot orders placed on the Indonesian site or spot fiat currency orders placed on the EU site. In other cases, an empty string is returned. Enum: feeType, subFeeType
884    pub extra_fees: Option<Vec<ExtraFee>>, // TODO: !!! ignore if empty string !!!
885    /// Cross sequence, used to associate each fill and each position update
886    /// The seq will be the same when conclude multiple transactions at the same time
887    /// Different symbols may have the same seq, please use seq + symbol to check unique
888    pub seq: i64,
889    /// Trading fee currency
890    pub fee_currency: String,
891}
892
893#[derive(PartialEq, Deserialize, Debug, Clone)]
894#[serde(rename_all = "camelCase")]
895pub struct ExtraFee {
896    pub fee_coin: String,
897    pub fee_type: ExtraFeeType,
898    pub sub_fee_type: ExtraSubFeeType,
899    pub fee_rate: Decimal,
900    pub fee: Decimal,
901}
902
903/// A fill from the `fastExecution` private WS topic.
904///
905/// This is a condensed view of an execution that arrives immediately when an
906/// order is matched, before the normal settlement cycle that drives
907/// [`ExecutionMsg`]. The field set is smaller than [`ExecutionMsg`].
908#[derive(PartialEq, Deserialize, Debug, Clone)]
909#[serde(rename_all = "camelCase")]
910pub struct FastExecutionMsg {
911    pub category: Category,
912    pub symbol: String,
913    pub exec_id: String,
914    pub exec_price: Decimal,
915    pub exec_qty: Decimal,
916    pub side: Side,
917    pub order_qty: Decimal,
918    pub order_id: String,
919    #[serde(default, deserialize_with = "empty_string_as_none")]
920    pub order_link_id: Option<String>,
921    pub is_maker: bool,
922    pub fee_currency: String,
923    pub fee_rate: Decimal,
924    pub exec_fee: Decimal,
925    #[serde(default, deserialize_with = "option_decimal")]
926    pub trade_iv: Option<Decimal>,
927    #[serde(default, deserialize_with = "option_decimal")]
928    pub mark_iv: Option<Decimal>,
929    pub mark_price: Decimal,
930    #[serde(default, deserialize_with = "option_decimal")]
931    pub index_price: Option<Decimal>,
932    #[serde(default, deserialize_with = "option_decimal")]
933    pub underlying_price: Option<Decimal>,
934    #[serde(default, deserialize_with = "empty_string_as_none")]
935    pub block_trade_id: Option<String>,
936    #[serde(deserialize_with = "number")]
937    pub exec_time: Timestamp,
938}
939
940/// A data point from the `greeks` private WS topic.
941///
942/// Sent once per second when there is an option position. Contains the
943/// aggregate net greeks across all option positions for the base coin.
944#[derive(PartialEq, Deserialize, Debug, Clone)]
945#[serde(rename_all = "camelCase")]
946pub struct GreekMsg {
947    pub base_coin: String,
948    pub total_delta: Decimal,
949    pub total_gamma: Decimal,
950    pub total_vega: Decimal,
951    pub total_theta: Decimal,
952}
953
954/// Data from the `dcp` private WS topic (Disconnection and Cancellation
955/// Protection status update).
956#[derive(PartialEq, Deserialize, Debug, Clone)]
957#[serde(rename_all = "camelCase")]
958pub struct DcpMsg {
959    /// Product class affected: e.g. `"OPTIONS"`, `"DERIVATIVES"`.
960    pub product: String,
961    /// Current DCP status. `"ON"` or `"OFF"`.
962    pub dcp_status: String,
963    /// The time window (seconds) configured for DCP.
964    pub time_window: u64,
965    /// Timestamp of the last status change (ms).
966    #[serde(deserialize_with = "number")]
967    pub updated_at: Timestamp,
968}
969
970#[cfg(test)]
971mod tests {
972    use rust_decimal::dec;
973
974    use crate::DepthLevel;
975    use crate::serde::{Unique, deserialize_json};
976
977    /// Parse with both the fast path and the derived untagged impl and check
978    /// that they agree.
979    fn parse(json: &str) -> IncomingMessage {
980        let fast = IncomingMessage::from_json(json).unwrap();
981        let untagged: IncomingMessage = deserialize_json(json).unwrap();
982        assert_eq!(fast, untagged);
983        fast
984    }
985
986    use super::*;
987
988    #[test]
989    fn deserialize_incoming_message_command_subscribe() {
990        let json = r#"{"success":true,"ret_msg":"","conn_id":"c0c928a4-daab-460d-b186-45e90a10a3d4","req_id":"","op":"subscribe"}"#;
991        let expected = IncomingMessage::Command(CommandMsg::Subscribe {
992            req_id: None,
993            ret_msg: None,
994            conn_id: String::from("c0c928a4-daab-460d-b186-45e90a10a3d4"),
995            success: true,
996        });
997
998        let message = parse(json);
999
1000        assert_eq!(expected, message);
1001    }
1002
1003    #[test]
1004    fn deserialize_incoming_message_command_unsubscribe() {
1005        let json = r#"{"success":true,"ret_msg":"","conn_id":"c0c928a4-daab-460d-b186-45e90a10a3d4","req_id":"","op":"unsubscribe"}"#;
1006        let expected = IncomingMessage::Command(CommandMsg::Unsubscribe {
1007            req_id: None,
1008            ret_msg: None,
1009            conn_id: String::from("c0c928a4-daab-460d-b186-45e90a10a3d4"),
1010            success: true,
1011        });
1012
1013        let message = parse(json);
1014
1015        assert_eq!(expected, message);
1016    }
1017
1018    #[test]
1019    fn deserialize_incoming_message_ticker_delta() {
1020        let json = r#"{
1021		    "topic": "tickers.BTCUSDT",
1022		    "type": "delta",
1023		    "data": {
1024		        "symbol": "BTCUSDT",
1025		        "tickDirection": "PlusTick",
1026		        "price24hPcnt": "-0.015895",
1027		        "lastPrice": "63948.50",
1028		        "turnover24h": "6793884423.5518",
1029		        "volume24h": "105991.3760",
1030		        "bid1Price": "63948.40",
1031		        "bid1Size": "3.439",
1032		        "ask1Price": "63948.50",
1033		        "ask1Size": "2.566"
1034		    },
1035		    "cs": 195377749067,
1036		    "ts": 1718995014034
1037		}"#;
1038        let ticker_delta = TickerMsg::Delta {
1039            topic: Topic::Ticker(String::from("BTCUSDT")),
1040            cs: Some(195377749067),
1041            ts: 1718995014034,
1042            data: TickerDeltaMsg {
1043                symbol: String::from("BTCUSDT"),
1044                tick_direction: Some(TickDirection::PlusTick),
1045                last_price: Some(dec!(63948.5)),
1046                pre_open_price: None,
1047                pre_qty: None,
1048                cur_pre_listing_phase: None,
1049                prev_price24h: None,
1050                price24h_pcnt: Some(dec!(-0.015895)),
1051                high_price24h: None,
1052                low_price24h: None,
1053                prev_price1h: None,
1054                mark_price: None,
1055                index_price: None,
1056                open_interest: None,
1057                open_interest_value: None,
1058                turnover24h: Some(dec!(6793884423.5518)),
1059                volume24h: Some(dec!(105991.376)),
1060                funding_rate: None,
1061                next_funding_time: None,
1062                bid1_price: Some(dec!(63948.4)),
1063                bid1_size: Some(dec!(3.439)),
1064                ask1_price: Some(dec!(63948.5)),
1065                ask1_size: Some(dec!(2.566)),
1066                delivery_time: None,
1067                basis_rate: None,
1068                delivery_fee_rate: None,
1069                predicted_delivery_price: None,
1070            },
1071        };
1072        let expected = IncomingMessage::Ticker(Box::new(ticker_delta));
1073
1074        let message = parse(json);
1075
1076        assert_eq!(expected, message);
1077    }
1078
1079    #[test]
1080    fn deserialize_incoming_message_ticker_snapshot() {
1081        // Category: linear.
1082        let json = r#"{
1083		    "topic": "tickers.BTCUSDT",
1084		    "type": "snapshot",
1085		    "data": {
1086                "symbol":"BTCUSDT",
1087                "tickDirection":"ZeroPlusTick",
1088                "price24hPcnt":"-0.044555",
1089                "lastPrice":"84594.40",
1090                "prevPrice24h":"88539.30",
1091                "highPrice24h":"89389.90",
1092                "lowPrice24h":"82055.60",
1093                "prevPrice1h":"84307.20",
1094                "markPrice":"84594.00",
1095                "indexPrice":"84650.47",
1096                "openInterest":"52903.75",
1097                "openInterestValue":"4475339827.50",
1098                "turnover24h":"17166562011.6514",
1099                "volume24h":"200176.9910",
1100                "nextFundingTime":"1740643200000",
1101                "fundingRate":"-0.00016974",
1102                "bid1Price":"84594.30",
1103                "bid1Size":"6.777",
1104                "ask1Price":"84594.40",
1105                "ask1Size":"0.660",
1106                "preOpenPrice":"",
1107                "preQty":"",
1108                "curPreListingPhase":""
1109		    },
1110		    "cs": 337149693308,
1111		    "ts": 1740622194359
1112		}"#;
1113        let ticker_snapshot = TickerMsg::Snapshot {
1114            topic: Topic::Ticker(String::from("BTCUSDT")),
1115            cs: Some(337149693308),
1116            ts: 1740622194359,
1117            data: TickerSnapshotMsg {
1118                symbol: String::from("BTCUSDT"),
1119                tick_direction: TickDirection::ZeroPlusTick,
1120                last_price: dec!(84594.40),
1121                pre_open_price: None,
1122                pre_qty: None,
1123                cur_pre_listing_phase: None,
1124                prev_price24h: dec!(88539.30),
1125                price24h_pcnt: dec!(-0.044555),
1126                high_price24h: dec!(89389.90),
1127                low_price24h: dec!(82055.60),
1128                prev_price1h: dec!(84307.20),
1129                mark_price: dec!(84594.00),
1130                index_price: dec!(84650.47),
1131                open_interest: dec!(52903.75),
1132                open_interest_value: dec!(4475339827.50),
1133                turnover24h: dec!(17166562011.6514),
1134                volume24h: dec!(200176.9910),
1135                funding_rate: dec!(-0.00016974),
1136                next_funding_time: 1740643200000,
1137                bid1_price: dec!(84594.30),
1138                bid1_size: dec!(6.777),
1139                ask1_price: dec!(84594.40),
1140                ask1_size: dec!(0.660),
1141                delivery_time: None,
1142                basis_rate: None,
1143                delivery_fee_rate: None,
1144                predicted_delivery_price: None,
1145            },
1146        };
1147        let expected = IncomingMessage::Ticker(Box::new(ticker_snapshot));
1148
1149        let message = parse(json);
1150
1151        assert_eq!(expected, message);
1152    }
1153
1154    #[test]
1155    fn deserialize_incoming_message_trade_snapshot() {
1156        // Category: linear.
1157        let json = r#"{
1158            "topic":"publicTrade.BTCUSDT",
1159            "type":"snapshot",
1160            "ts":1741433245359,
1161            "data":[
1162                {
1163                    "T":1741433245357,
1164                    "s":"BTCUSDT",
1165                    "S":"Buy",
1166                    "v":"0.007",
1167                    "p":"85821.00",
1168                    "L":"PlusTick",
1169                    "i":"485eaa70-df6e-5260-bbef-4f7324e3c5d9",
1170                    "BT":false
1171                }
1172            ]
1173        }"#;
1174        let expected = IncomingMessage::Trade(TradeMsg::Snapshot {
1175            id: None,
1176            topic: Topic::Trade(String::from("BTCUSDT")),
1177            ts: 1741433245359,
1178            data: vec![TradeSnapshotMsg {
1179                time: 1741433245357,
1180                symbol: String::from("BTCUSDT"),
1181                side: Side::Buy,
1182                size: dec!(0.007),
1183                price: dec!(85821.00),
1184                tick_direction: TickDirection::PlusTick,
1185                trade_id: String::from("485eaa70-df6e-5260-bbef-4f7324e3c5d9"),
1186                block_trade: false,
1187                rpi_trade: None,
1188                mark_price: None,
1189                index_price: None,
1190                mark_iv: None,
1191                iv: None,
1192            }],
1193        });
1194
1195        let message = parse(json);
1196
1197        assert_eq!(expected, message);
1198    }
1199
1200    #[test]
1201    fn deserialize_incoming_message_orderbook_snapshot() {
1202        // Category: linear.
1203        let json = r#"{
1204            "topic":"orderbook.50.BTCUSDT",
1205            "type":"snapshot",
1206            "ts":1672304484978,
1207            "data":{
1208                "s":"BTCUSDT",
1209                "b":[
1210                    ["16493.50","0.006"],
1211                    ["16493.00","0.100"]
1212                ],
1213                "a":[
1214                    ["16611.00","0.029"],
1215                    ["16612.00","0.213"]
1216                ],
1217                "u":18521288,
1218                "seq":7961638724
1219            },
1220            "cts":1672304484976
1221        }"#;
1222        let expected = IncomingMessage::Orderbook(OrderbookMsg::Snapshot {
1223            topic: Topic::Orderbook {
1224                symbol: String::from("BTCUSDT"),
1225                depth: DepthLevel::Level50,
1226            },
1227            ts: 1672304484978,
1228            data: OrderbookDataMsg {
1229                symbol: String::from("BTCUSDT"),
1230                bids: vec![
1231                    OrderbookLevel {
1232                        price: dec!(16493.50),
1233                        size: dec!(0.006),
1234                    },
1235                    OrderbookLevel {
1236                        price: dec!(16493.00),
1237                        size: dec!(0.100),
1238                    },
1239                ],
1240                asks: vec![
1241                    OrderbookLevel {
1242                        price: dec!(16611.00),
1243                        size: dec!(0.029),
1244                    },
1245                    OrderbookLevel {
1246                        price: dec!(16612.00),
1247                        size: dec!(0.213),
1248                    },
1249                ],
1250                update_id: 18521288,
1251                seq: 7961638724,
1252            },
1253            cts: 1672304484976,
1254        });
1255
1256        let message = parse(json);
1257
1258        assert_eq!(expected, message);
1259    }
1260
1261    #[test]
1262    fn deserialize_incoming_message_orderbook_delta() {
1263        // Category: linear.
1264        let json = r#"{
1265            "topic":"orderbook.50.BTCUSDT",
1266            "type":"delta",
1267            "ts":1687940967466,
1268            "data":{
1269                "s":"BTCUSDT",
1270                "b":[
1271                    ["30247.20","30.028"],
1272                    ["30245.40","0.224"],
1273                    ["30242.10","0.000"]
1274                ],
1275                "a":[
1276                    ["30248.67","0.033"],
1277                    ["30249.20","0.000"]
1278                ],
1279                "u":177400507,
1280                "seq":66544703342
1281            },
1282            "cts":1687940967464
1283        }"#;
1284        let expected = IncomingMessage::Orderbook(OrderbookMsg::Delta {
1285            topic: Topic::Orderbook {
1286                symbol: String::from("BTCUSDT"),
1287                depth: DepthLevel::Level50,
1288            },
1289            ts: 1687940967466,
1290            data: OrderbookDataMsg {
1291                symbol: String::from("BTCUSDT"),
1292                bids: vec![
1293                    OrderbookLevel {
1294                        price: dec!(30247.20),
1295                        size: dec!(30.028),
1296                    },
1297                    OrderbookLevel {
1298                        price: dec!(30245.40),
1299                        size: dec!(0.224),
1300                    },
1301                    OrderbookLevel {
1302                        price: dec!(30242.10),
1303                        size: dec!(0.000),
1304                    },
1305                ],
1306                asks: vec![
1307                    OrderbookLevel {
1308                        price: dec!(30248.67),
1309                        size: dec!(0.033),
1310                    },
1311                    OrderbookLevel {
1312                        price: dec!(30249.20),
1313                        size: dec!(0.000),
1314                    },
1315                ],
1316                update_id: 177400507,
1317                seq: 66544703342,
1318            },
1319            cts: 1687940967464,
1320        });
1321
1322        let message = parse(json);
1323
1324        assert_eq!(expected, message);
1325    }
1326
1327    #[test]
1328    fn deserialize_incoming_message_all_liquidation_snapshot() {
1329        // Category: linear.
1330        let json = r#"{
1331            "topic":"allLiquidation.BTCUSDT",
1332            "type":"snapshot",
1333            "ts":1741450605553,
1334            "data":[
1335                {
1336                    "T":1741450605236,
1337                    "s":"BTCUSDT",
1338                    "S":"Buy",
1339                    "v":"0.001",
1340                    "p":"85823.60"
1341                }
1342            ]
1343        }"#;
1344        let expected = AllLiquidationMsg::Snapshot {
1345            topic: Topic::AllLiquidation(String::from("BTCUSDT")),
1346            ts: 1741450605553,
1347            data: vec![AllLiquidationSnapshotMsg {
1348                time: 1741450605236,
1349                symbol: String::from("BTCUSDT"),
1350                side: Side::Buy,
1351                size: dec!(0.001),
1352                price: dec!(85823.60),
1353            }],
1354        };
1355
1356        let message = parse(json);
1357
1358        assert_eq!(IncomingMessage::AllLiquidation(expected), message);
1359    }
1360
1361    #[test]
1362    fn deserialize_incoming_message_order() {
1363        let json = r#"{
1364            "id": "5923240c6880ab-c59f-420b-9adb-3639adc9dd90",
1365            "topic": "order",
1366            "creationTime": 1672364262474,
1367            "data": [
1368                {
1369                    "symbol": "ETH-30DEC22-1400-C",
1370                    "orderId": "5cf98598-39a7-459e-97bf-76ca765ee020",
1371                    "side": "Sell",
1372                    "orderType": "Market",
1373                    "cancelType": "UNKNOWN",
1374                    "price": "72.5",
1375                    "qty": "1",
1376                    "orderIv": "",
1377                    "timeInForce": "IOC",
1378                    "orderStatus": "Filled",
1379                    "orderLinkId": "",
1380                    "lastPriceOnCreated": "",
1381                    "reduceOnly": false,
1382                    "leavesQty": "",
1383                    "leavesValue": "",
1384                    "cumExecQty": "1",
1385                    "cumExecValue": "75",
1386                    "avgPrice": "75",
1387                    "blockTradeId": "",
1388                    "positionIdx": 0,
1389                    "cumExecFee": "0.358635",
1390                    "closedPnl": "0",
1391                    "createdTime": "1672364262444",
1392                    "updatedTime": "1672364262457",
1393                    "rejectReason": "EC_NoError",
1394                    "stopOrderType": "",
1395                    "tpslMode": "",
1396                    "triggerPrice": "",
1397                    "takeProfit": "",
1398                    "stopLoss": "",
1399                    "tpTriggerBy": "",
1400                    "slTriggerBy": "",
1401                    "tpLimitPrice": "",
1402                    "slLimitPrice": "",
1403                    "triggerDirection": 0,
1404                    "triggerBy": "",
1405                    "closeOnTrigger": false,
1406                    "category": "option",
1407                    "placeType": "price",
1408                    "smpType": "None",
1409                    "smpGroup": 0,
1410                    "smpOrderId": "",
1411                    "feeCurrency": "",
1412                    "cumFeeDetail": {
1413                        "MNT": "0.00242968"
1414                    }
1415                }
1416            ]
1417        }"#;
1418        let order = PrivateMsg {
1419            id: String::from("5923240c6880ab-c59f-420b-9adb-3639adc9dd90"),
1420            creation_time: 1672364262474,
1421            data: vec![OrderMsg {
1422                category: Category::Option,
1423                order_id: String::from("5cf98598-39a7-459e-97bf-76ca765ee020"),
1424                order_link_id: None,
1425                is_leverage: None,
1426                block_trade_id: None,
1427                symbol: String::from("ETH-30DEC22-1400-C"),
1428                price: dec!(72.5),
1429                broker_order_price: None,
1430                qty: dec!(1.0),
1431                side: Side::Sell,
1432                position_idx: PositionIdx::OneWay,
1433                order_status: OrderStatus::Filled,
1434                create_type: None,
1435                cancel_type: CancelType::UNKNOWN,
1436                reject_reason: RejectReason::EcNoError,
1437                avg_price: Some(dec!(75.0)),
1438                leaves_qty: None,
1439                leaves_value: None,
1440                cum_exec_qty: dec!(1.0),
1441                cum_exec_value: dec!(75.0),
1442                cum_exec_fee: dec!(0.358635),
1443                closed_pnl: dec!(0.0),
1444                fee_currency: None,
1445                time_in_force: TimeInForce::IOC,
1446                order_type: OrderType::Market,
1447                stop_order_type: None,
1448                oco_trigger_by: None,
1449                order_iv: None,
1450                market_unit: None,
1451                slippage_tolerance_type: None,
1452                slippage_tolerance: None,
1453                trigger_price: None,
1454                take_profit: None,
1455                stop_loss: None,
1456                tpsl_mode: None,
1457                tp_limit_price: None,
1458                sl_limit_price: None,
1459                tp_trigger_by: None,
1460                sl_trigger_by: None,
1461                trigger_direction: TriggerDirection::UNKNOWN,
1462                trigger_by: None,
1463                last_price_on_created: None,
1464                reduce_only: false,
1465                close_on_trigger: false,
1466                place_type: Some(PlaceType::Price),
1467                smp_type: SmpType::None,
1468                smp_group: 0,
1469                smp_order_id: None,
1470                created_time: 1672364262444,
1471                updated_time: 1672364262457,
1472                cum_fee_detail: Some(serde_json::from_str(r#"{"MNT": "0.00242968"}"#).unwrap()),
1473            }],
1474        };
1475        let expected = IncomingMessage::Topic(TopicMessage::Order(order));
1476
1477        let message = parse(json);
1478
1479        assert_eq!(expected, message);
1480    }
1481
1482    #[test]
1483    fn deserialize_incoming_message_order2() {
1484        let json = r#"{"topic":"order","id":"108985347_ADAUSDT_140667095077548","creationTime":1766436947942,"data":[{"category":"linear","symbol":"ADAUSDT","orderId":"ae802ad5-af70-4957-ba72-86ad7fc9c24d","orderLinkId":"","blockTradeId":"","side":"Buy","positionIdx":0,"orderStatus":"Filled","cancelType":"UNKNOWN","rejectReason":"EC_NoError","timeInForce":"IOC","isLeverage":"","price":"0.3862","qty":"15","avgPrice":"0.3679","leavesQty":"0","leavesValue":"0","cumExecQty":"15","cumExecValue":"5.5185","cumExecFee":"0.00303518","orderType":"Market","stopOrderType":"","orderIv":"","triggerPrice":"","takeProfit":"","stopLoss":"","triggerBy":"","tpTriggerBy":"","slTriggerBy":"","triggerDirection":0,"placeType":"","lastPriceOnCreated":"0.3679","closeOnTrigger":false,"reduceOnly":false,"smpGroup":0,"smpType":"None","smpOrderId":"","slLimitPrice":"0","tpLimitPrice":"0","tpslMode":"UNKNOWN","createType":"CreateByUser","marketUnit":"","createdTime":"1766436947940","updatedTime":"1766436947940","feeCurrency":"","closedPnl":"0","slippageTolerance":"0","slippageToleranceType":"UNKNOWN","cumFeeDetail":{}}]}"#;
1485        let order = PrivateMsg {
1486            id: String::from("108985347_ADAUSDT_140667095077548"),
1487            creation_time: 1766436947942,
1488            data: vec![OrderMsg {
1489                category: Category::Linear,
1490                order_id: String::from("ae802ad5-af70-4957-ba72-86ad7fc9c24d"),
1491                order_link_id: None,
1492                is_leverage: None,
1493                block_trade_id: None,
1494                symbol: String::from("ADAUSDT"),
1495                price: dec!(0.3862),
1496                broker_order_price: None,
1497                qty: dec!(15),
1498                side: Side::Buy,
1499                position_idx: PositionIdx::OneWay,
1500                order_status: OrderStatus::Filled,
1501                create_type: Some(CreateType::CreateByUser),
1502                cancel_type: CancelType::UNKNOWN,
1503                reject_reason: RejectReason::EcNoError,
1504                avg_price: Some(dec!(0.3679)),
1505                leaves_qty: Some(dec!(0)),
1506                leaves_value: Some(dec!(0)),
1507                cum_exec_qty: dec!(15),
1508                cum_exec_value: dec!(5.5185),
1509                cum_exec_fee: dec!(0.00303518),
1510                closed_pnl: dec!(0),
1511                fee_currency: None,
1512                time_in_force: TimeInForce::IOC,
1513                order_type: OrderType::Market,
1514                stop_order_type: None,
1515                oco_trigger_by: None,
1516                order_iv: None,
1517                market_unit: None,
1518                slippage_tolerance_type: Some(SlippageToleranceType::UNKNOWN),
1519                slippage_tolerance: Some(dec!(0)),
1520                trigger_price: None,
1521                take_profit: None,
1522                stop_loss: None,
1523                tpsl_mode: Some(TpslMode::UNKNOWN),
1524                tp_limit_price: Some(dec!(0)),
1525                sl_limit_price: Some(dec!(0)),
1526                tp_trigger_by: None,
1527                sl_trigger_by: None,
1528                trigger_direction: TriggerDirection::UNKNOWN,
1529                trigger_by: None,
1530                last_price_on_created: Some(dec!(0.3679)),
1531                reduce_only: false,
1532                close_on_trigger: false,
1533                place_type: None,
1534                smp_type: SmpType::None,
1535                smp_group: 0,
1536                smp_order_id: None,
1537                created_time: 1766436947940,
1538                updated_time: 1766436947940,
1539                cum_fee_detail: Some(serde_json::from_str(r#"{}"#).unwrap()),
1540            }],
1541        };
1542        let expected = IncomingMessage::Topic(TopicMessage::Order(order));
1543
1544        let message = parse(json);
1545
1546        assert_eq!(expected, message);
1547    }
1548
1549    #[test]
1550    fn deserialize_incoming_message_order3() {
1551        let json = r#"{"topic":"order","id":"108985347_ADAUSDT_140667102632416","creationTime":1766600379878,"data":[{"category":"linear","symbol":"ADAUSDT","orderId":"f0468cbc-ed2f-4fd7-9620-998f3e9f387c","orderLinkId":"BOT_LINK_ID-1","blockTradeId":"","side":"Buy","positionIdx":0,"orderStatus":"New","cancelType":"UNKNOWN","rejectReason":"EC_NoError","timeInForce":"GTC","isLeverage":"","price":"0.3539","qty":"15","avgPrice":"","leavesQty":"15","leavesValue":"5.3085","cumExecQty":"0","cumExecValue":"0","cumExecFee":"0","orderType":"Limit","stopOrderType":"","orderIv":"","triggerPrice":"","takeProfit":"","stopLoss":"","triggerBy":"","tpTriggerBy":"","slTriggerBy":"","triggerDirection":0,"placeType":"","lastPriceOnCreated":"0.355","closeOnTrigger":false,"reduceOnly":false,"smpGroup":0,"smpType":"None","smpOrderId":"","slLimitPrice":"0","tpLimitPrice":"0","tpslMode":"UNKNOWN","createType":"CreateByUser","marketUnit":"","createdTime":"1766600379876","updatedTime":"1766600379876","feeCurrency":"","closedPnl":"0","slippageTolerance":"0","slippageToleranceType":"UNKNOWN","cumFeeDetail":{}}]}"#;
1552        let order = PrivateMsg {
1553            id: String::from("108985347_ADAUSDT_140667102632416"),
1554            creation_time: 1766600379878,
1555            data: vec![OrderMsg {
1556                category: Category::Linear,
1557                order_id: String::from("f0468cbc-ed2f-4fd7-9620-998f3e9f387c"),
1558                order_link_id: Some(String::from("BOT_LINK_ID-1")),
1559                is_leverage: None,
1560                block_trade_id: None,
1561                symbol: String::from("ADAUSDT"),
1562                price: dec!(0.3539),
1563                broker_order_price: None,
1564                qty: dec!(15),
1565                side: Side::Buy,
1566                position_idx: PositionIdx::OneWay,
1567                order_status: OrderStatus::New,
1568                create_type: Some(CreateType::CreateByUser),
1569                cancel_type: CancelType::UNKNOWN,
1570                reject_reason: RejectReason::EcNoError,
1571                avg_price: None,
1572                leaves_qty: Some(dec!(15)),
1573                leaves_value: Some(dec!(5.3085)),
1574                cum_exec_qty: dec!(0),
1575                cum_exec_value: dec!(0),
1576                cum_exec_fee: dec!(0),
1577                closed_pnl: dec!(0),
1578                fee_currency: None,
1579                time_in_force: TimeInForce::GTC,
1580                order_type: OrderType::Limit,
1581                stop_order_type: None,
1582                oco_trigger_by: None,
1583                order_iv: None,
1584                market_unit: None,
1585                slippage_tolerance_type: Some(SlippageToleranceType::UNKNOWN),
1586                slippage_tolerance: Some(dec!(0)),
1587                trigger_price: None,
1588                take_profit: None,
1589                stop_loss: None,
1590                tpsl_mode: Some(TpslMode::UNKNOWN),
1591                tp_limit_price: Some(dec!(0)),
1592                sl_limit_price: Some(dec!(0)),
1593                tp_trigger_by: None,
1594                sl_trigger_by: None,
1595                trigger_direction: TriggerDirection::UNKNOWN,
1596                trigger_by: None,
1597                last_price_on_created: Some(dec!(0.355)),
1598                reduce_only: false,
1599                close_on_trigger: false,
1600                place_type: None, // "smpGroup":0,"smpType":"None","smpOrderId":"",
1601                smp_type: SmpType::None,
1602                smp_group: 0,
1603                smp_order_id: None,
1604                created_time: 1766600379876,
1605                updated_time: 1766600379876,
1606                cum_fee_detail: Some(serde_json::from_str(r#"{}"#).unwrap()),
1607            }],
1608        };
1609        let expected = IncomingMessage::Topic(TopicMessage::Order(order));
1610
1611        let message = parse(json);
1612
1613        assert_eq!(expected, message);
1614    }
1615
1616    #[test]
1617    fn deserialize_incoming_message_position() {
1618        let json = r#"{
1619            "id": "108985347_position_1765659601915",
1620            "topic": "position",
1621            "creationTime": 1765659601915,
1622            "data": [
1623                {
1624                    "positionIdx": 1,
1625                    "tradeMode": 0,
1626                    "riskId": 116,
1627                    "riskLimitValue": "200000",
1628                    "symbol": "ADAUSDT",
1629                    "side": "Buy",
1630                    "size": "18720",
1631                    "entryPrice": "0.41160027",
1632                    "sessionAvgPrice": "",
1633                    "leverage": "75",
1634                    "positionValue": "7705.157",
1635                    "positionBalance": "0",
1636                    "markPrice": "0.41",
1637                    "positionIM": "106.51735757",
1638                    "positionMM": "61.74535757",
1639                    "positionIMByMp": "106.51735757",
1640                    "positionMMByMp": "61.74535757",
1641                    "takeProfit": "0.4321",
1642                    "stopLoss": "0.3704",
1643                    "trailingStop": "0",
1644                    "unrealisedPnl": "-29.957",
1645                    "cumRealisedPnl": "-6712.87804378",
1646                    "curRealisedPnl": "-2.6317147",
1647                    "createdTime": "1714594321840",
1648                    "updatedTime": "1765645142548",
1649                    "tpslMode": "Full",
1650                    "liqPrice": "0.37000066",
1651                    "bustPrice": "",
1652                    "category": "linear",
1653                    "positionStatus": "Normal",
1654                    "adlRankIndicator": 2,
1655                    "autoAddMargin": 0,
1656                    "leverageSysUpdatedTime": "",
1657                    "mmrSysUpdatedTime": "",
1658                    "seq": 140667058318085,
1659                    "isReduceOnly": false
1660                },
1661                {
1662                    "positionIdx": 2,
1663                    "tradeMode": 0,
1664                    "riskId": 116,
1665                    "riskLimitValue": "200000",
1666                    "symbol": "ADAUSDT",
1667                    "side": "",
1668                    "size": "0",
1669                    "entryPrice": "0",
1670                    "sessionAvgPrice": "",
1671                    "leverage": "75",
1672                    "positionValue": "0",
1673                    "positionBalance": "0",
1674                    "markPrice": "0.41",
1675                    "positionIM": "",
1676                    "positionMM": "",
1677                    "positionIMByMp": "",
1678                    "positionMMByMp": "",
1679                    "takeProfit": "0",
1680                    "stopLoss": "0",
1681                    "trailingStop": "0",
1682                    "unrealisedPnl": "0",
1683                    "cumRealisedPnl": "1618.30675974",
1684                    "curRealisedPnl": "0",
1685                    "createdTime": "1714594321840",
1686                    "updatedTime": "1765046350698",
1687                    "tpslMode": "Full",
1688                    "liqPrice": "0",
1689                    "bustPrice": "",
1690                    "category": "linear",
1691                    "positionStatus": "Normal",
1692                    "adlRankIndicator": 0,
1693                    "autoAddMargin": 0,
1694                    "leverageSysUpdatedTime": "",
1695                    "mmrSysUpdatedTime": "",
1696                    "seq": 140667031311361,
1697                    "isReduceOnly": false
1698                }
1699            ]
1700        }"#;
1701        let position = PrivateMsg {
1702            id: String::from("108985347_position_1765659601915"),
1703            creation_time: 1765659601915,
1704            data: vec![
1705                PositionMsg {
1706                    category: Category::Linear,
1707                    symbol: String::from("ADAUSDT"),
1708                    side: Some(Side::Buy),
1709                    size: dec!(18720),
1710                    position_idx: PositionIdx::Buy,
1711                    position_value: dec!(7705.157),
1712                    risk_id: 116,
1713                    risk_limit_value: Some(dec!(200000)),
1714                    entry_price: dec!(0.41160027),
1715                    mark_price: dec!(0.41),
1716                    leverage: dec!(75),
1717                    auto_add_margin: false,
1718                    position_im: Some(dec!(106.51735757)),
1719                    position_mm: Some(dec!(61.74535757)),
1720                    position_im_by_mp: Some(dec!(106.51735757)),
1721                    position_mm_by_mp: Some(dec!(61.74535757)),
1722                    liq_price: Some(dec!(0.37000066)),
1723                    take_profit: dec!(0.4321),
1724                    stop_loss: dec!(0.3704),
1725                    trailing_stop: dec!(0),
1726                    unrealised_pnl: dec!(-29.957),
1727                    cur_realised_pnl: dec!(-2.6317147),
1728                    session_avg_price: None,
1729                    delta: None,
1730                    gamma: None,
1731                    vega: None,
1732                    theta: None,
1733                    cum_realised_pnl: dec!(-6712.87804378),
1734                    position_status: PositionStatus::Normal,
1735                    adl_rank_indicator: AdlRankIndicator::Two,
1736                    is_reduce_only: false,
1737                    mmr_sys_updated_time: None,
1738                    leverage_sys_updated_time: None,
1739                    created_time: 1714594321840,
1740                    updated_time: 1765645142548,
1741                    seq: 140667058318085,
1742                },
1743                PositionMsg {
1744                    category: Category::Linear,
1745                    symbol: String::from("ADAUSDT"),
1746                    side: None,
1747                    size: dec!(0),
1748                    position_idx: PositionIdx::Sell,
1749                    position_value: dec!(0),
1750                    risk_id: 116,
1751                    risk_limit_value: Some(dec!(200000)),
1752                    entry_price: dec!(0),
1753                    mark_price: dec!(0.41),
1754                    leverage: dec!(75),
1755                    auto_add_margin: false,
1756                    position_im: None,
1757                    position_mm: None,
1758                    position_im_by_mp: None,
1759                    position_mm_by_mp: None,
1760                    liq_price: Some(dec!(0)),
1761                    take_profit: dec!(0),
1762                    stop_loss: dec!(0),
1763                    trailing_stop: dec!(0),
1764                    unrealised_pnl: dec!(0),
1765                    cur_realised_pnl: dec!(0),
1766                    session_avg_price: None,
1767                    delta: None,
1768                    gamma: None,
1769                    vega: None,
1770                    theta: None,
1771                    cum_realised_pnl: dec!(1618.30675974),
1772                    position_status: PositionStatus::Normal,
1773                    adl_rank_indicator: AdlRankIndicator::Zero,
1774                    is_reduce_only: false,
1775                    mmr_sys_updated_time: None,
1776                    leverage_sys_updated_time: None,
1777                    created_time: 1714594321840,
1778                    updated_time: 1765046350698,
1779                    seq: 140667031311361,
1780                },
1781            ],
1782        };
1783        let expected = IncomingMessage::Topic(TopicMessage::Position(position));
1784
1785        let message = parse(json);
1786
1787        assert_eq!(expected, message);
1788    }
1789
1790    #[test]
1791    fn deserialize_incoming_message_position2() {
1792        let json = r#"{
1793            "id":"108985347_position_1766316605952",
1794            "topic":"position",
1795            "creationTime":1766316605952,
1796            "data":[
1797                {
1798                "positionIdx":1,
1799                "tradeMode":0,
1800                "riskId":116,
1801                "riskLimitValue":"200000",
1802                "symbol":"ADAUSDT",
1803                "side":"Buy",
1804                "size":"43",
1805                "entryPrice":"0.37293023",
1806                "sessionAvgPrice":"",
1807                "leverage":"75",
1808                "positionValue":"16.036",
1809                "positionBalance":"0",
1810                "markPrice":"0.3702",
1811                "positionIM":"0.22095025",
1812                "positionMM":"0.12809175",
1813                "positionIMByMp":"0.22095025",
1814                "positionMMByMp":"0.12809175",
1815                "takeProfit":"0",
1816                "stopLoss":"0",
1817                "trailingStop":"0",
1818                "unrealisedPnl":"-0.1174",
1819                "cumRealisedPnl":"-7547.8530836",
1820                "curRealisedPnl":"-0.00465061",
1821                "createdTime":"1714594321840",
1822                "updatedTime":"1766313370061",
1823                "tpslMode":"Full",
1824                "liqPrice":"",
1825                "bustPrice":"",
1826                "category":"linear",
1827                "positionStatus":"Normal",
1828                "adlRankIndicator":2,
1829                "autoAddMargin":0,
1830                "leverageSysUpdatedTime":"",
1831                "mmrSysUpdatedTime":"",
1832                "seq":140667089523042,
1833                "isReduceOnly":false
1834                },
1835                {
1836                "positionIdx":2,
1837                "tradeMode":0,
1838                "riskId":116,
1839                "riskLimitValue":"200000",
1840                "symbol":"ADAUSDT",
1841                "side":"",
1842                "size":"0",
1843                "entryPrice":"0",
1844                "sessionAvgPrice":"",
1845                "leverage":"75",
1846                "positionValue":"0",
1847                "positionBalance":"0",
1848                "markPrice":"0.3702",
1849                "positionIM":"",
1850                "positionMM":"",
1851                "positionIMByMp":"",
1852                "positionMMByMp":"",
1853                "takeProfit":"0",
1854                "stopLoss":"0",
1855                "trailingStop":"0",
1856                "unrealisedPnl":"0",
1857                "cumRealisedPnl":"1618.30675974",
1858                "curRealisedPnl":"0",
1859                "createdTime":"1714594321840",
1860                "updatedTime":"1765046350698",
1861                "tpslMode":"Full",
1862                "liqPrice":"0",
1863                "bustPrice":"",
1864                "category":"linear",
1865                "positionStatus":"Normal",
1866                "adlRankIndicator":0,
1867                "autoAddMargin":0,
1868                "leverageSysUpdatedTime":"",
1869                "mmrSysUpdatedTime":"",
1870                "seq":140667031311361,
1871                "isReduceOnly":false
1872                }
1873            ]
1874        }"#;
1875        let position = PrivateMsg {
1876            id: String::from("108985347_position_1766316605952"),
1877            creation_time: 1766316605952,
1878            data: vec![
1879                PositionMsg {
1880                    category: Category::Linear,
1881                    symbol: String::from("ADAUSDT"),
1882                    side: Some(Side::Buy),
1883                    size: dec!(43),
1884                    position_idx: PositionIdx::Buy,
1885                    position_value: dec!(16.036),
1886                    risk_id: 116,
1887                    risk_limit_value: Some(dec!(200000)),
1888                    entry_price: dec!(0.37293023),
1889                    mark_price: dec!(0.3702),
1890                    leverage: dec!(75),
1891                    auto_add_margin: false,
1892                    position_im: Some(dec!(0.22095025)),
1893                    position_mm: Some(dec!(0.12809175)),
1894                    position_im_by_mp: Some(dec!(0.22095025)),
1895                    position_mm_by_mp: Some(dec!(0.12809175)),
1896                    liq_price: None,
1897                    take_profit: dec!(0),
1898                    stop_loss: dec!(0),
1899                    trailing_stop: dec!(0),
1900                    unrealised_pnl: dec!(-0.1174),
1901                    cur_realised_pnl: dec!(-0.00465061),
1902                    session_avg_price: None,
1903                    delta: None,
1904                    gamma: None,
1905                    vega: None,
1906                    theta: None,
1907                    cum_realised_pnl: dec!(-7547.8530836),
1908                    position_status: PositionStatus::Normal,
1909                    adl_rank_indicator: AdlRankIndicator::Two,
1910                    is_reduce_only: false,
1911                    mmr_sys_updated_time: None,
1912                    leverage_sys_updated_time: None,
1913                    created_time: 1714594321840,
1914                    updated_time: 1766313370061,
1915                    seq: 140667089523042,
1916                },
1917                PositionMsg {
1918                    category: Category::Linear,
1919                    symbol: String::from("ADAUSDT"),
1920                    side: None,
1921                    size: dec!(0),
1922                    position_idx: PositionIdx::Sell,
1923                    position_value: dec!(0),
1924                    risk_id: 116,
1925                    risk_limit_value: Some(dec!(200000)),
1926                    entry_price: dec!(0),
1927                    mark_price: dec!(0.3702),
1928                    leverage: dec!(75),
1929                    auto_add_margin: false,
1930                    position_im: None,
1931                    position_mm: None,
1932                    position_im_by_mp: None,
1933                    position_mm_by_mp: None,
1934                    liq_price: Some(dec!(0)),
1935                    take_profit: dec!(0),
1936                    stop_loss: dec!(0),
1937                    trailing_stop: dec!(0),
1938                    unrealised_pnl: dec!(0),
1939                    cur_realised_pnl: dec!(0),
1940                    session_avg_price: None,
1941                    delta: None,
1942                    gamma: None,
1943                    vega: None,
1944                    theta: None,
1945                    cum_realised_pnl: dec!(1618.30675974),
1946                    position_status: PositionStatus::Normal,
1947                    adl_rank_indicator: AdlRankIndicator::Zero,
1948                    is_reduce_only: false,
1949                    mmr_sys_updated_time: None,
1950                    leverage_sys_updated_time: None,
1951                    created_time: 1714594321840,
1952                    updated_time: 1765046350698,
1953                    seq: 140667031311361,
1954                },
1955            ],
1956        };
1957        let expected = IncomingMessage::Topic(TopicMessage::Position(position));
1958
1959        let message = parse(json);
1960
1961        assert_eq!(expected, message);
1962    }
1963
1964    #[test]
1965    fn deserialize_incoming_message_wallet() {
1966        let json = r#"{
1967            "id": "592324d2bce751-ad38-48eb-8f42-4671d1fb4d4e",
1968            "topic": "wallet",
1969            "creationTime": 1700034722104,
1970            "data": [
1971                {
1972                    "accountIMRate": "0",
1973                    "accountIMRateByMp": "0",
1974                    "accountMMRate": "0",
1975                    "accountMMRateByMp": "0",
1976                    "totalEquity": "10262.91335023",
1977                    "totalWalletBalance": "9684.46297164",
1978                    "totalMarginBalance": "9684.46297164",
1979                    "totalAvailableBalance": "9556.6056555",
1980                    "totalPerpUPL": "0",
1981                    "totalInitialMargin": "0",
1982                    "totalInitialMarginByMp": "0",
1983                    "totalMaintenanceMargin": "0",
1984                    "totalMaintenanceMarginByMp": "0",
1985                    "coin": [
1986                        {
1987                            "coin": "BTC",
1988                            "equity": "0.00102964",
1989                            "usdValue": "36.70759517",
1990                            "walletBalance": "0.00102964",
1991                            "availableToWithdraw": "0.00102964",
1992                            "availableToBorrow": "",
1993                            "borrowAmount": "0",
1994                            "accruedInterest": "0",
1995                            "totalOrderIM": "",
1996                            "totalPositionIM": "",
1997                            "totalPositionMM": "",
1998                            "unrealisedPnl": "0",
1999                            "cumRealisedPnl": "-0.00000973",
2000                            "bonus": "0",
2001                            "collateralSwitch": true,
2002                            "marginCollateral": true,
2003                            "locked": "0",
2004                            "spotHedgingQty": "0.01592413",
2005                            "spotBorrow": "0"
2006                        }
2007                    ],
2008                    "accountLTV": "0",
2009                    "accountType": "UNIFIED"
2010                }
2011            ]
2012        }"#;
2013        let coin = WalletCoin {
2014            coin: String::from("BTC"),
2015            equity: dec!(0.00102964),
2016            usd_value: dec!(36.70759517),
2017            wallet_balance: dec!(0.00102964),
2018            locked: dec!(0),
2019            spot_hedging_qty: dec!(0.01592413),
2020            borrow_amount: dec!(0),
2021            accrued_interest: dec!(0),
2022            total_order_im: None,
2023            total_position_im: None,
2024            total_position_mm: None,
2025            unrealised_pnl: dec!(0),
2026            cum_realised_pnl: dec!(-0.00000973),
2027            bonus: dec!(0),
2028            collateral_switch: true,
2029            margin_collateral: true,
2030            spot_borrow: Some(dec!(0)),
2031        };
2032        let coin = HashMap::from([(Unique::unique_key(&coin), coin)]);
2033        let wallet = PrivateMsg {
2034            id: String::from("592324d2bce751-ad38-48eb-8f42-4671d1fb4d4e"),
2035            creation_time: 1700034722104,
2036            data: vec![WalletMsg {
2037                account_type: AccountType::UNIFIED,
2038                account_im_rate: dec!(0),
2039                account_im_rate_by_mp: dec!(0),
2040                account_mm_rate: dec!(0),
2041                account_mm_rate_by_mp: dec!(0),
2042                total_equity: dec!(10262.91335023),
2043                total_wallet_balance: dec!(9684.46297164),
2044                total_margin_balance: dec!(9684.46297164),
2045                total_available_balance: dec!(9556.6056555),
2046                total_perp_upl: dec!(0),
2047                total_initial_margin: dec!(0),
2048                total_initial_margin_by_mp: dec!(0),
2049                total_maintenance_margin: dec!(0),
2050                total_maintenance_margin_by_mp: dec!(0),
2051                coin,
2052            }],
2053        };
2054        let expected = IncomingMessage::Topic(TopicMessage::Wallet(wallet));
2055
2056        let message = parse(json);
2057
2058        assert_eq!(expected, message);
2059    }
2060
2061    #[test]
2062    fn deserialize_incoming_message_wallet2() {
2063        let json = r#"{
2064            "id":"108985347_wallet_1766318882965",
2065            "topic":"wallet",
2066            "creationTime":1766318882964,
2067            "data":[
2068                {
2069                "accountIMRate":"0.0007",
2070                "accountMMRate":"0.0004",
2071                "accountIMRateByMp":"0.0007",
2072                "accountMMRateByMp":"0.0004",
2073                "totalEquity":"102.7094181",
2074                "totalWalletBalance":"102.16591975",
2075                "totalMarginBalance":"102.16591975",
2076                "totalAvailableBalance":"102.09402758",
2077                "totalPerpUPL":"0",
2078                "totalInitialMargin":"0.07189217",
2079                "totalMaintenanceMargin":"0.04166941",
2080                "totalInitialMarginByMp":"0.07189217",
2081                "totalMaintenanceMarginByMp":"0.04166941",
2082                "coin":[
2083                    {
2084                    "coin":"USDT",
2085                    "equity":"75.5601152",
2086                    "usdValue":"75.53450032",
2087                    "walletBalance":"75.5601152",
2088                    "availableToWithdraw":"",
2089                    "availableToBorrow":"",
2090                    "borrowAmount":"0",
2091                    "accruedInterest":"0",
2092                    "totalOrderIM":"0",
2093                    "totalPositionIM":"0.07191655",
2094                    "totalPositionMM":"0.04168355",
2095                    "unrealisedPnl":"0",
2096                    "cumRealisedPnl":"36163.8134634",
2097                    "bonus":"0",
2098                    "collateralSwitch":true,
2099                    "marginCollateral":true,
2100                    "locked":"0",
2101                    "spotHedgingQty":"0"
2102                    }
2103                ],
2104                "accountLTV":"0",
2105                "accountType":"UNIFIED"
2106                }
2107            ]
2108            }"#;
2109        let coin = WalletCoin {
2110            coin: String::from("USDT"),
2111            equity: dec!(75.5601152),
2112            usd_value: dec!(75.53450032),
2113            wallet_balance: dec!(75.5601152),
2114            locked: dec!(0),
2115            spot_hedging_qty: dec!(0),
2116            borrow_amount: dec!(0),
2117            accrued_interest: dec!(0),
2118            total_order_im: Some(dec!(0)),
2119            total_position_im: Some(dec!(0.07191655)),
2120            total_position_mm: Some(dec!(0.04168355)),
2121            unrealised_pnl: dec!(0),
2122            cum_realised_pnl: dec!(36163.8134634),
2123            bonus: dec!(0),
2124            collateral_switch: true,
2125            margin_collateral: true,
2126            spot_borrow: None,
2127        };
2128        let coin = HashMap::from([(Unique::unique_key(&coin), coin)]);
2129        let wallet = PrivateMsg {
2130            id: String::from("108985347_wallet_1766318882965"),
2131            creation_time: 1766318882964,
2132            data: vec![WalletMsg {
2133                account_type: AccountType::UNIFIED,
2134                account_im_rate: dec!(0.0007),
2135                account_im_rate_by_mp: dec!(0.0007),
2136                account_mm_rate: dec!(0.0004),
2137                account_mm_rate_by_mp: dec!(0.0004),
2138                total_equity: dec!(102.7094181),
2139                total_wallet_balance: dec!(102.16591975),
2140                total_margin_balance: dec!(102.16591975),
2141                total_available_balance: dec!(102.09402758),
2142                total_perp_upl: dec!(0),
2143                total_initial_margin: dec!(0.07189217),
2144                total_initial_margin_by_mp: dec!(0.07189217),
2145                total_maintenance_margin: dec!(0.04166941),
2146                total_maintenance_margin_by_mp: dec!(0.04166941),
2147                coin,
2148            }],
2149        };
2150        let expected = IncomingMessage::Topic(TopicMessage::Wallet(wallet));
2151
2152        let message = parse(json);
2153
2154        assert_eq!(expected, message);
2155    }
2156
2157    #[test]
2158    fn deserialize_incoming_message_execution() {
2159        let json = r#"{
2160            "topic": "execution",
2161            "id": "386825804_BTCUSDT_140612148849382",
2162            "creationTime": 1746270400355,
2163            "data": [
2164                {
2165                    "category": "linear",
2166                    "symbol": "BTCUSDT",
2167                    "closedSize": "0.5",
2168                    "execFee": "26.3725275",
2169                    "execId": "0ab1bdf7-4219-438b-b30a-32ec863018f7",
2170                    "execPrice": "95900.1",
2171                    "execQty": "0.5",
2172                    "execType": "Trade",
2173                    "execValue": "47950.05",
2174                    "feeRate": "0.00055",
2175                    "tradeIv": "",
2176                    "markIv": "",
2177                    "blockTradeId": "",
2178                    "markPrice": "95901.48",
2179                    "indexPrice": "",
2180                    "underlyingPrice": "",
2181                    "leavesQty": "0",
2182                    "orderId": "9aac161b-8ed6-450d-9cab-c5cc67c21784",
2183                    "orderLinkId": "",
2184                    "orderPrice": "94942.5",
2185                    "orderQty": "0.5",
2186                    "orderType": "Market",
2187                    "stopOrderType": "UNKNOWN",
2188                    "side": "Sell",
2189                    "execTime": "1746270400353",
2190                    "isLeverage": "0",
2191                    "isMaker": false,
2192                    "seq": 140612148849382,
2193                    "marketUnit": "",
2194                    "execPnl": "0.05",
2195                    "createType": "CreateByUser",
2196                    "extraFees":[{"feeCoin":"USDT","feeType":"GST","subFeeType":"IND_GST","feeRate":"0.0000675","fee":"0.006403779"}],
2197                    "feeCurrency": "USDT"
2198                }
2199            ]
2200        }"#;
2201        let execution = PrivateMsg {
2202            id: String::from("386825804_BTCUSDT_140612148849382"),
2203            creation_time: 1746270400355,
2204            data: vec![ExecutionMsg {
2205                category: Category::Linear,
2206                symbol: String::from("BTCUSDT"),
2207                is_leverage: false,
2208                order_id: String::from("9aac161b-8ed6-450d-9cab-c5cc67c21784"),
2209                order_link_id: None,
2210                side: Side::Sell,
2211                order_price: dec!(94942.5),
2212                order_qty: dec!(0.5),
2213                leaves_qty: dec!(0),
2214                create_type: CreateType::CreateByUser,
2215                order_type: OrderType::Market,
2216                stop_order_type: StopOrderType::UNKNOWN,
2217                exec_fee: dec!(26.3725275),
2218                exec_id: String::from("0ab1bdf7-4219-438b-b30a-32ec863018f7"),
2219                exec_price: dec!(95900.1),
2220                exec_qty: dec!(0.5),
2221                exec_pnl: dec!(0.05),
2222                exec_type: ExecType::Trade,
2223                exec_value: dec!(47950.05),
2224                exec_time: 1746270400353,
2225                is_maker: false,
2226                fee_rate: dec!(0.00055),
2227                trade_iv: None,
2228                mark_iv: None,
2229                mark_price: dec!(95901.48),
2230                index_price: None,
2231                underlying_price: None,
2232                block_trade_id: None,
2233                closed_size: dec!(0.5),
2234                extra_fees: Some(vec![ExtraFee {
2235                    fee_coin: String::from("USDT"),
2236                    fee_type: ExtraFeeType::Gst,
2237                    sub_fee_type: ExtraSubFeeType::IndGst,
2238                    fee_rate: dec!(0.0000675),
2239                    fee: dec!(0.006403779),
2240                }]),
2241                seq: 140612148849382,
2242                fee_currency: String::from("USDT"),
2243            }],
2244        };
2245        let expected = IncomingMessage::Topic(TopicMessage::Execution(execution));
2246
2247        let message = parse(json);
2248
2249        assert_eq!(expected, message);
2250    }
2251
2252    #[test]
2253    fn from_json_recognizes_topic_with_category_suffix() {
2254        let json = r#"{
2255            "id": "5923240c6880ab-c59f-420b-9adb-3639adc9dd90",
2256            "topic": "order.option",
2257            "creationTime": 1672364262474,
2258            "data": [
2259                {
2260                    "symbol": "ETH-30DEC22-1400-C",
2261                    "orderId": "5cf98598-39a7-459e-97bf-76ca765ee020",
2262                    "side": "Sell",
2263                    "orderType": "Market",
2264                    "cancelType": "UNKNOWN",
2265                    "price": "72.5",
2266                    "qty": "1",
2267                    "orderIv": "",
2268                    "timeInForce": "IOC",
2269                    "orderStatus": "Filled",
2270                    "orderLinkId": "",
2271                    "lastPriceOnCreated": "",
2272                    "reduceOnly": false,
2273                    "leavesQty": "",
2274                    "leavesValue": "",
2275                    "cumExecQty": "1",
2276                    "cumExecValue": "75",
2277                    "avgPrice": "75",
2278                    "blockTradeId": "",
2279                    "positionIdx": 0,
2280                    "cumExecFee": "0.358635",
2281                    "closedPnl": "0",
2282                    "createdTime": "1672364262444",
2283                    "updatedTime": "1672364262457",
2284                    "rejectReason": "EC_NoError",
2285                    "stopOrderType": "",
2286                    "tpslMode": "",
2287                    "triggerPrice": "",
2288                    "takeProfit": "",
2289                    "stopLoss": "",
2290                    "tpTriggerBy": "",
2291                    "slTriggerBy": "",
2292                    "tpLimitPrice": "",
2293                    "slLimitPrice": "",
2294                    "triggerDirection": 0,
2295                    "triggerBy": "",
2296                    "closeOnTrigger": false,
2297                    "category": "option",
2298                    "placeType": "price",
2299                    "smpType": "None",
2300                    "smpGroup": 0,
2301                    "smpOrderId": "",
2302                    "feeCurrency": "",
2303                    "cumFeeDetail": {
2304                        "MNT": "0.00242968"
2305                    }
2306                }
2307            ]
2308        }"#;
2309
2310        let message = IncomingMessage::from_json(json).unwrap();
2311
2312        assert!(matches!(
2313            message,
2314            IncomingMessage::Topic(TopicMessage::Order(PrivateMsg { ref data, .. })) if data.len() == 1
2315        ));
2316    }
2317
2318    #[test]
2319    fn from_json_error_describes_the_problem_instead_of_untagged_mismatch() {
2320        let json = r#"{
2321            "topic":"orderbook.50.BTCUSDT",
2322            "type":"delta",
2323            "ts":1687940967466,
2324            "data":{"s":"BTCUSDT","b":[["abc","30.028"]],"a":[],"u":1,"seq":1},
2325            "cts":1687940967464
2326        }"#;
2327
2328        let fast = IncomingMessage::from_json(json).unwrap_err().to_string();
2329        let untagged = deserialize_json::<IncomingMessage>(json)
2330            .unwrap_err()
2331            .to_string();
2332
2333        assert!(untagged.contains("did not match any variant"), "{untagged}");
2334        assert!(!fast.contains("did not match any variant"), "{fast}");
2335    }
2336}