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