Skip to main content

nautilus_bybit/websocket/
messages.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
7//
8//  Unless required by applicable law or agreed to in writing, software
9//  distributed under the License is distributed on an "AS IS" BASIS,
10//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11//  See the License for the specific language governing permissions and
12//  limitations under the License.
13// -------------------------------------------------------------------------------------------------
14
15//! WebSocket message types for Bybit public and private channels.
16
17use std::fmt::Debug;
18
19use nautilus_core::string::secret::{REDACTED, zeroize_json_value};
20use rust_decimal::Decimal;
21use serde::{Deserialize, Serialize};
22use serde_json::Value;
23use ustr::Ustr;
24use zeroize::Zeroize;
25
26use crate::{
27    common::{
28        enums::{
29            BybitBboSideType, BybitCancelType, BybitCreateType, BybitExecType, BybitMarketUnit,
30            BybitOrderSide, BybitOrderSmpType, BybitOrderStatus, BybitOrderType, BybitPositionIdx,
31            BybitPositionSide, BybitPositionStatus, BybitProductType, BybitSmpType,
32            BybitStopOrderType, BybitTimeInForce, BybitTpSlMode, BybitTriggerDirection,
33            BybitTriggerType, BybitWsOrderRequestOp,
34        },
35        parse::{
36            deserialize_decimal_or_zero, deserialize_i32_or_string, deserialize_i64_or_string,
37            deserialize_optional_decimal_or_zero, deserialize_optional_decimal_str,
38        },
39    },
40    websocket::enums::BybitWsOperation,
41};
42
43/// Bybit WebSocket subscription message.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct BybitSubscription {
46    pub op: BybitWsOperation,
47    pub args: Vec<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub req_id: Option<String>,
50}
51
52/// Bybit WebSocket authentication message.
53#[derive(Clone, Serialize, Deserialize)]
54pub struct BybitAuthRequest {
55    pub op: BybitWsOperation,
56    pub args: Vec<serde_json::Value>,
57}
58
59impl Debug for BybitAuthRequest {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct(stringify!(BybitAuthRequest))
62            .field("op", &self.op)
63            .field("args", &REDACTED)
64            .finish()
65    }
66}
67
68impl Zeroize for BybitAuthRequest {
69    fn zeroize(&mut self) {
70        for arg in &mut self.args {
71            zeroize_json_value(arg);
72        }
73    }
74}
75
76/// Wire-level frame deserialized from a Bybit WebSocket message.
77///
78/// Represents the raw protocol layer before the handler converts data
79/// variants into the public [`BybitWsMessage`] API.
80#[derive(Debug, Clone)]
81pub enum BybitWsFrame {
82    /// Authentication acknowledgement.
83    Auth(BybitWsAuthResponse),
84    /// Subscription acknowledgement.
85    Subscription(BybitWsSubscriptionMsg),
86    /// Order operation response (create/amend/cancel) from trade WebSocket.
87    OrderResponse(BybitWsOrderResponse),
88    /// Error response from the venue.
89    ErrorResponse(BybitWsResponse),
90    /// Orderbook snapshot or delta.
91    Orderbook(BybitWsOrderbookDepthMsg),
92    /// Trade updates.
93    Trade(BybitWsTradeMsg),
94    /// Public liquidation updates.
95    Liquidation(BybitWsLiquidationMsg),
96    /// Kline updates.
97    Kline(BybitWsKlineMsg),
98    /// Linear/inverse ticker update.
99    TickerLinear(BybitWsTickerLinearMsg),
100    /// Option ticker update.
101    TickerOption(BybitWsTickerOptionMsg),
102    /// Order updates from private channel.
103    AccountOrder(BybitWsAccountOrderMsg),
104    /// Execution/fill updates from private channel.
105    AccountExecution(BybitWsAccountExecutionMsg),
106    /// Fast execution updates from private channel (slim payload).
107    AccountExecutionFast(BybitWsAccountExecutionFastMsg),
108    /// Wallet/balance updates from private channel.
109    AccountWallet(BybitWsAccountWalletMsg),
110    /// Position updates from private channel.
111    AccountPosition(BybitWsAccountPositionMsg),
112    /// Payload that does not match any known frame type.
113    Unknown(Value),
114    /// Notification that the underlying connection reconnected.
115    Reconnected,
116}
117
118/// High-level message emitted by the Bybit WebSocket client.
119#[derive(Debug, Clone)]
120pub enum BybitWsMessage {
121    /// Authentication acknowledgement.
122    Auth(BybitWsAuthResponse),
123    /// Order operation response (create/amend/cancel) from trade WebSocket.
124    OrderResponse(BybitWsOrderResponse),
125    /// Orderbook snapshot or delta.
126    Orderbook(BybitWsOrderbookDepthMsg),
127    /// Trade updates.
128    Trade(BybitWsTradeMsg),
129    /// Public liquidation updates.
130    Liquidation(BybitWsLiquidationMsg),
131    /// Kline updates.
132    Kline(BybitWsKlineMsg),
133    /// Linear/inverse ticker update.
134    TickerLinear(BybitWsTickerLinearMsg),
135    /// Option ticker update.
136    TickerOption(BybitWsTickerOptionMsg),
137    /// Order updates from private channel.
138    AccountOrder(BybitWsAccountOrderMsg),
139    /// Execution/fill updates from private channel.
140    AccountExecution(BybitWsAccountExecutionMsg),
141    /// Fast execution updates from private channel (slim payload).
142    AccountExecutionFast(BybitWsAccountExecutionFastMsg),
143    /// Wallet/balance updates from private channel.
144    AccountWallet(BybitWsAccountWalletMsg),
145    /// Position updates from private channel.
146    AccountPosition(BybitWsAccountPositionMsg),
147    /// Error received from the venue or client lifecycle.
148    Error(BybitWebSocketError),
149    /// Notification that the underlying connection reconnected.
150    Reconnected,
151}
152
153/// Represents an error event surfaced by the WebSocket client.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155#[serde(rename_all = "camelCase")]
156pub struct BybitWebSocketError {
157    /// Error/return code reported by Bybit.
158    pub code: i64,
159    /// Human readable message.
160    pub message: String,
161    /// Optional connection identifier.
162    #[serde(default)]
163    pub conn_id: Option<String>,
164    /// Optional topic associated with the error (when applicable).
165    #[serde(default)]
166    pub topic: Option<String>,
167    /// Optional request identifier related to the failure.
168    #[serde(default)]
169    pub req_id: Option<String>,
170}
171
172impl BybitWebSocketError {
173    /// Creates a new error with the provided code/message.
174    #[must_use]
175    pub fn new(code: i64, message: impl Into<String>) -> Self {
176        Self {
177            code,
178            message: message.into(),
179            conn_id: None,
180            topic: None,
181            req_id: None,
182        }
183    }
184
185    /// Builds an error payload from a generic response frame.
186    #[must_use]
187    pub fn from_response(response: &BybitWsResponse) -> Self {
188        // Build a more informative error message when ret_msg is missing
189        let message = response.ret_msg.clone().unwrap_or_else(|| {
190            let mut parts = vec![];
191
192            if let Some(op) = &response.op {
193                parts.push(format!("op={op}"));
194            }
195
196            if let Some(topic) = &response.topic {
197                parts.push(format!("topic={topic}"));
198            }
199
200            if let Some(success) = response.success {
201                parts.push(format!("success={success}"));
202            }
203
204            if parts.is_empty() {
205                "Bybit websocket error (no error message provided)".to_string()
206            } else {
207                format!("Bybit websocket error: {}", parts.join(", "))
208            }
209        });
210
211        Self {
212            code: response.ret_code.unwrap_or_default(),
213            message,
214            conn_id: response.conn_id.clone(),
215            topic: response.topic.map(|t| t.to_string()),
216            req_id: response.req_id.clone(),
217        }
218    }
219
220    /// Convenience constructor for client-side errors (e.g. parsing failures).
221    #[must_use]
222    pub fn from_message(message: impl Into<String>) -> Self {
223        Self::new(-1, message)
224    }
225}
226
227/// Generic WebSocket request for Bybit trading commands.
228#[derive(Debug, Clone, Serialize)]
229#[serde(rename_all = "camelCase")]
230pub struct BybitWsRequest<T> {
231    /// Request ID for correlation (will be echoed back in response).
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub req_id: Option<String>,
234    /// Operation type (order.create, order.amend, order.cancel, etc.).
235    pub op: BybitWsOrderRequestOp,
236    /// Request header containing timestamp and other metadata.
237    pub header: BybitWsHeader,
238    /// Arguments payload for the operation.
239    pub args: Vec<T>,
240}
241
242/// Header for WebSocket trade requests.
243#[derive(Debug, Clone, Serialize)]
244#[serde(rename_all = "SCREAMING-KEBAB-CASE")]
245pub struct BybitWsHeader {
246    /// Timestamp in milliseconds.
247    pub x_bapi_timestamp: String,
248    /// Optional referer ID.
249    #[serde(rename = "Referer", skip_serializing_if = "Option::is_none")]
250    pub referer: Option<String>,
251}
252
253impl BybitWsHeader {
254    /// Creates a new header with the current timestamp.
255    #[must_use]
256    pub fn now() -> Self {
257        Self::with_referer(None)
258    }
259
260    /// Creates a new header with the current timestamp and optional referer.
261    #[must_use]
262    pub fn with_referer(referer: Option<String>) -> Self {
263        use nautilus_core::time::get_atomic_clock_realtime;
264        Self {
265            x_bapi_timestamp: get_atomic_clock_realtime().get_time_ms().to_string(),
266            referer,
267        }
268    }
269}
270
271/// Parameters for placing an order via WebSocket.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(rename_all = "camelCase")]
274pub struct BybitWsPlaceOrderParams {
275    pub category: BybitProductType,
276    pub symbol: Ustr,
277    pub side: BybitOrderSide,
278    pub order_type: BybitOrderType,
279    pub qty: String,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub is_leverage: Option<i32>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub market_unit: Option<BybitMarketUnit>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub price: Option<String>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub time_in_force: Option<BybitTimeInForce>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub order_link_id: Option<String>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub reduce_only: Option<bool>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub close_on_trigger: Option<bool>,
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub trigger_price: Option<String>,
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub trigger_by: Option<BybitTriggerType>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub trigger_direction: Option<i32>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub tpsl_mode: Option<BybitTpSlMode>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub take_profit: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub stop_loss: Option<String>,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub tp_trigger_by: Option<BybitTriggerType>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub sl_trigger_by: Option<BybitTriggerType>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub sl_trigger_price: Option<String>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub tp_trigger_price: Option<String>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub sl_order_type: Option<BybitOrderType>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub tp_order_type: Option<BybitOrderType>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub sl_limit_price: Option<String>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub tp_limit_price: Option<String>,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub order_iv: Option<String>,
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub smp_type: Option<BybitOrderSmpType>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub mmp: Option<bool>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub position_idx: Option<BybitPositionIdx>,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub bbo_side_type: Option<BybitBboSideType>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub bbo_level: Option<String>,
334}
335
336/// Parameters for amending an order via WebSocket.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338#[serde(rename_all = "camelCase")]
339pub struct BybitWsAmendOrderParams {
340    pub category: BybitProductType,
341    pub symbol: Ustr,
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub order_id: Option<String>,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub order_link_id: Option<String>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub qty: Option<String>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub price: Option<String>,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub trigger_price: Option<String>,
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub take_profit: Option<String>,
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub stop_loss: Option<String>,
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub tp_trigger_by: Option<BybitTriggerType>,
358    #[serde(skip_serializing_if = "Option::is_none")]
359    pub sl_trigger_by: Option<BybitTriggerType>,
360    #[serde(skip_serializing_if = "Option::is_none")]
361    pub order_iv: Option<String>,
362}
363
364/// Item in a batch amend request (without category field).
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct BybitWsBatchAmendItem {
368    pub symbol: Ustr,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub order_id: Option<String>,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub order_link_id: Option<String>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub qty: Option<String>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub price: Option<String>,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub trigger_price: Option<String>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub take_profit: Option<String>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub stop_loss: Option<String>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub tp_trigger_by: Option<BybitTriggerType>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub sl_trigger_by: Option<BybitTriggerType>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub order_iv: Option<String>,
389}
390
391/// Arguments for batch amend order operation via WebSocket.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct BybitWsBatchAmendOrderArgs {
394    pub category: BybitProductType,
395    pub request: Vec<BybitWsBatchAmendItem>,
396}
397
398/// Parameters for canceling an order via WebSocket.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400#[serde(rename_all = "camelCase")]
401pub struct BybitWsCancelOrderParams {
402    pub category: BybitProductType,
403    pub symbol: Ustr,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub order_id: Option<String>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub order_link_id: Option<String>,
408}
409
410/// Item in a batch cancel request (without category field).
411#[derive(Debug, Clone, Serialize, Deserialize)]
412#[serde(rename_all = "camelCase")]
413pub struct BybitWsBatchCancelItem {
414    pub symbol: Ustr,
415    #[serde(skip_serializing_if = "Option::is_none")]
416    pub order_id: Option<String>,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub order_link_id: Option<String>,
419}
420
421/// Arguments for batch cancel order operation via WebSocket.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct BybitWsBatchCancelOrderArgs {
424    pub category: BybitProductType,
425    pub request: Vec<BybitWsBatchCancelItem>,
426}
427
428/// Item in a batch place request (same as BybitWsPlaceOrderParams but without category).
429#[derive(Debug, Clone, Serialize, Deserialize)]
430#[serde(rename_all = "camelCase")]
431pub struct BybitWsBatchPlaceItem {
432    pub symbol: Ustr,
433    pub side: BybitOrderSide,
434    pub order_type: BybitOrderType,
435    pub qty: String,
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub is_leverage: Option<i32>,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub market_unit: Option<BybitMarketUnit>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub price: Option<String>,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub time_in_force: Option<BybitTimeInForce>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub order_link_id: Option<String>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub reduce_only: Option<bool>,
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub close_on_trigger: Option<bool>,
450    #[serde(skip_serializing_if = "Option::is_none")]
451    pub trigger_price: Option<String>,
452    #[serde(skip_serializing_if = "Option::is_none")]
453    pub trigger_by: Option<BybitTriggerType>,
454    #[serde(skip_serializing_if = "Option::is_none")]
455    pub trigger_direction: Option<i32>,
456    #[serde(skip_serializing_if = "Option::is_none")]
457    pub tpsl_mode: Option<BybitTpSlMode>,
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub take_profit: Option<String>,
460    #[serde(skip_serializing_if = "Option::is_none")]
461    pub stop_loss: Option<String>,
462    #[serde(skip_serializing_if = "Option::is_none")]
463    pub tp_trigger_by: Option<BybitTriggerType>,
464    #[serde(skip_serializing_if = "Option::is_none")]
465    pub sl_trigger_by: Option<BybitTriggerType>,
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub sl_trigger_price: Option<String>,
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub tp_trigger_price: Option<String>,
470    #[serde(skip_serializing_if = "Option::is_none")]
471    pub sl_order_type: Option<BybitOrderType>,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub tp_order_type: Option<BybitOrderType>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub sl_limit_price: Option<String>,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub tp_limit_price: Option<String>,
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub order_iv: Option<String>,
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub smp_type: Option<BybitOrderSmpType>,
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub mmp: Option<bool>,
484    #[serde(skip_serializing_if = "Option::is_none")]
485    pub position_idx: Option<BybitPositionIdx>,
486    #[serde(skip_serializing_if = "Option::is_none")]
487    pub bbo_side_type: Option<BybitBboSideType>,
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub bbo_level: Option<String>,
490}
491
492/// Arguments for batch place order operation via WebSocket.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct BybitWsBatchPlaceOrderArgs {
495    pub category: BybitProductType,
496    pub request: Vec<BybitWsBatchPlaceItem>,
497}
498
499/// Subscription acknowledgement returned by Bybit.
500#[derive(Clone, Debug, Serialize, Deserialize)]
501pub struct BybitWsSubscriptionMsg {
502    pub success: bool,
503    pub op: BybitWsOperation,
504    #[serde(default)]
505    pub conn_id: Option<String>,
506    #[serde(default)]
507    pub req_id: Option<String>,
508    #[serde(default)]
509    pub ret_msg: Option<String>,
510}
511
512/// Generic response returned by the endpoint when subscribing or authenticating.
513#[derive(Clone, Debug, Serialize, Deserialize)]
514pub struct BybitWsResponse {
515    #[serde(default)]
516    pub op: Option<BybitWsOperation>,
517    #[serde(default)]
518    pub topic: Option<Ustr>,
519    #[serde(default)]
520    pub success: Option<bool>,
521    #[serde(default)]
522    pub conn_id: Option<String>,
523    #[serde(default)]
524    pub req_id: Option<String>,
525    #[serde(default)]
526    pub ret_code: Option<i64>,
527    #[serde(default)]
528    pub ret_msg: Option<String>,
529}
530
531/// Order operation response from WebSocket trade API.
532#[derive(Clone, Debug, Serialize, Deserialize)]
533#[serde(rename_all = "camelCase")]
534pub struct BybitWsOrderResponse {
535    /// Operation type (order.create, order.amend, order.cancel).
536    pub op: Ustr,
537    /// Connection ID.
538    #[serde(default)]
539    pub conn_id: Option<String>,
540    /// Return code (0 = success, non-zero = error).
541    pub ret_code: i64,
542    /// Return message.
543    pub ret_msg: String,
544    /// Response data (usually empty for errors, may contain order details for success).
545    #[serde(default)]
546    pub data: Value,
547    /// Request ID for correlation (echoed back if provided in request).
548    #[serde(default)]
549    pub req_id: Option<String>,
550    /// Request header containing timestamp and rate limit info.
551    #[serde(default)]
552    pub header: Option<Value>,
553    /// Extended info for errors.
554    #[serde(default)]
555    pub ret_ext_info: Option<Value>,
556}
557
558impl BybitWsOrderResponse {
559    /// Extracts individual order errors from retExtInfo for batch operations.
560    ///
561    /// For batch operations, even when ret_code is 0, individual orders may fail.
562    /// These failures are reported in retExtInfo.list as an array of {code, msg} objects.
563    #[must_use]
564    pub fn extract_batch_errors(&self) -> Vec<BybitBatchOrderError> {
565        self.ret_ext_info
566            .as_ref()
567            .and_then(|ext| ext.get("list"))
568            .and_then(|list| list.as_array())
569            .map(|arr| {
570                arr.iter()
571                    .filter_map(|item| {
572                        let code = item.get("code")?.as_i64()?;
573                        let msg = item.get("msg")?.as_str()?.to_string();
574                        Some(BybitBatchOrderError { code, msg })
575                    })
576                    .collect()
577            })
578            .unwrap_or_default()
579    }
580}
581
582/// Error information for individual orders in a batch operation.
583#[derive(Clone, Debug)]
584pub struct BybitBatchOrderError {
585    /// Error code (0 = success, non-zero = error).
586    pub code: i64,
587    /// Error message.
588    pub msg: String,
589}
590
591/// Authentication acknowledgement for private channels.
592#[derive(Clone, Debug, Serialize, Deserialize)]
593#[serde(rename_all = "camelCase")]
594pub struct BybitWsAuthResponse {
595    pub op: BybitWsOperation,
596    #[serde(default)]
597    pub conn_id: Option<String>,
598    #[serde(default)]
599    pub ret_code: Option<i64>,
600    #[serde(default)]
601    pub ret_msg: Option<String>,
602    #[serde(default)]
603    pub success: Option<bool>,
604}
605
606/// Representation of a kline/candlestick event on the public stream.
607#[derive(Clone, Debug, Serialize, Deserialize)]
608#[serde(rename_all = "camelCase")]
609pub struct BybitWsKline {
610    pub start: i64,
611    pub end: i64,
612    pub interval: Ustr,
613    pub open: String,
614    pub close: String,
615    pub high: String,
616    pub low: String,
617    pub volume: String,
618    pub turnover: String,
619    pub confirm: bool,
620    pub timestamp: i64,
621}
622
623/// Envelope for kline updates.
624#[derive(Clone, Debug, Serialize, Deserialize)]
625#[serde(rename_all = "camelCase")]
626pub struct BybitWsKlineMsg {
627    pub topic: Ustr,
628    pub ts: i64,
629    #[serde(rename = "type")]
630    pub msg_type: Ustr,
631    pub data: Vec<BybitWsKline>,
632}
633
634/// Orderbook depth payload consisting of raw ladder deltas.
635#[derive(Clone, Debug, Serialize, Deserialize)]
636pub struct BybitWsOrderbookDepth {
637    /// Symbol.
638    pub s: Ustr,
639    /// Bid levels represented as `[price, size]` string pairs.
640    pub b: Vec<Vec<String>>,
641    /// Ask levels represented as `[price, size]` string pairs.
642    pub a: Vec<Vec<String>>,
643    /// Update identifier.
644    pub u: i64,
645    /// Cross sequence number.
646    pub seq: i64,
647}
648
649/// Envelope for orderbook depth snapshots and updates.
650#[derive(Clone, Debug, Serialize, Deserialize)]
651#[serde(rename_all = "camelCase")]
652pub struct BybitWsOrderbookDepthMsg {
653    pub topic: Ustr,
654    #[serde(rename = "type")]
655    pub msg_type: Ustr,
656    pub ts: i64,
657    pub data: BybitWsOrderbookDepth,
658    #[serde(default)]
659    pub cts: Option<i64>,
660}
661
662/// Linear/Inverse ticker event payload.
663#[derive(Clone, Debug, Serialize, Deserialize)]
664#[serde(rename_all = "camelCase")]
665pub struct BybitWsTickerLinear {
666    pub symbol: Ustr,
667    #[serde(default)]
668    pub tick_direction: Option<String>,
669    #[serde(default)]
670    pub price24h_pcnt: Option<String>,
671    #[serde(default)]
672    pub last_price: Option<String>,
673    #[serde(default)]
674    pub prev_price24h: Option<String>,
675    #[serde(default)]
676    pub high_price24h: Option<String>,
677    #[serde(default)]
678    pub low_price24h: Option<String>,
679    #[serde(default)]
680    pub prev_price1h: Option<String>,
681    #[serde(default)]
682    pub mark_price: Option<String>,
683    #[serde(default)]
684    pub index_price: Option<String>,
685    #[serde(default)]
686    pub open_interest: Option<String>,
687    #[serde(default)]
688    pub open_interest_value: Option<String>,
689    #[serde(default)]
690    pub turnover24h: Option<String>,
691    #[serde(default)]
692    pub volume24h: Option<String>,
693    #[serde(default)]
694    pub next_funding_time: Option<String>,
695    #[serde(default)]
696    pub funding_rate: Option<String>,
697    #[serde(default)]
698    pub bid1_price: Option<String>,
699    #[serde(default)]
700    pub bid1_size: Option<String>,
701    #[serde(default)]
702    pub ask1_price: Option<String>,
703    #[serde(default)]
704    pub ask1_size: Option<String>,
705    #[serde(default)]
706    pub funding_interval_hour: Option<String>,
707}
708
709/// Envelope for linear ticker updates.
710#[derive(Clone, Debug, Serialize, Deserialize)]
711#[serde(rename_all = "camelCase")]
712pub struct BybitWsTickerLinearMsg {
713    pub topic: Ustr,
714    #[serde(rename = "type")]
715    pub msg_type: Ustr,
716    pub ts: i64,
717    #[serde(default)]
718    pub cs: Option<i64>,
719    pub data: BybitWsTickerLinear,
720}
721
722/// Option ticker event payload.
723#[derive(Clone, Debug, Serialize, Deserialize)]
724#[serde(rename_all = "camelCase")]
725pub struct BybitWsTickerOption {
726    pub symbol: Ustr,
727    pub bid_price: String,
728    pub bid_size: String,
729    pub bid_iv: String,
730    pub ask_price: String,
731    pub ask_size: String,
732    pub ask_iv: String,
733    pub last_price: String,
734    pub high_price24h: String,
735    pub low_price24h: String,
736    pub mark_price: String,
737    pub index_price: String,
738    pub mark_price_iv: String,
739    pub underlying_price: String,
740    pub open_interest: String,
741    pub turnover24h: String,
742    pub volume24h: String,
743    pub total_volume: String,
744    pub total_turnover: String,
745    pub delta: String,
746    pub gamma: String,
747    pub vega: String,
748    pub theta: String,
749    pub predicted_delivery_price: String,
750    pub change24h: String,
751}
752
753/// Envelope for option ticker updates.
754#[derive(Clone, Debug, Serialize, Deserialize)]
755#[serde(rename_all = "camelCase")]
756pub struct BybitWsTickerOptionMsg {
757    #[serde(default)]
758    pub id: Option<String>,
759    pub topic: Ustr,
760    #[serde(rename = "type")]
761    pub msg_type: Ustr,
762    pub ts: i64,
763    pub data: BybitWsTickerOption,
764}
765
766/// Trade event payload containing trade executions on public feeds.
767#[derive(Clone, Debug, Serialize, Deserialize)]
768pub struct BybitWsTrade {
769    #[serde(rename = "T")]
770    pub t: i64,
771    #[serde(rename = "s")]
772    pub s: Ustr,
773    #[serde(rename = "S")]
774    pub taker_side: BybitOrderSide,
775    #[serde(rename = "v")]
776    pub v: String,
777    #[serde(rename = "p")]
778    pub p: String,
779    #[serde(rename = "i")]
780    pub i: String,
781    #[serde(rename = "BT")]
782    pub bt: bool,
783    #[serde(rename = "L")]
784    #[serde(default)]
785    pub l: Option<String>,
786    #[serde(rename = "id")]
787    #[serde(default)]
788    pub id: Option<Ustr>,
789    #[serde(rename = "mP")]
790    #[serde(default)]
791    pub m_p: Option<String>,
792    #[serde(rename = "iP")]
793    #[serde(default)]
794    pub i_p: Option<String>,
795    #[serde(rename = "mIv")]
796    #[serde(default)]
797    pub m_iv: Option<String>,
798    #[serde(rename = "iv")]
799    #[serde(default)]
800    pub iv: Option<String>,
801}
802
803/// Envelope for public trade updates.
804#[derive(Clone, Debug, Serialize, Deserialize)]
805#[serde(rename_all = "camelCase")]
806pub struct BybitWsTradeMsg {
807    pub topic: Ustr,
808    #[serde(rename = "type")]
809    pub msg_type: Ustr,
810    pub ts: i64,
811    pub data: Vec<BybitWsTrade>,
812}
813
814/// Liquidation entry from the public `allLiquidation` stream.
815///
816/// `S` is the side of the liquidated position, where `Buy` means a long position was liquidated.
817#[derive(Clone, Debug, Serialize, Deserialize)]
818pub struct BybitWsLiquidation {
819    #[serde(rename = "T")]
820    pub t: i64,
821    #[serde(rename = "s")]
822    pub s: Ustr,
823    #[serde(rename = "S")]
824    pub side: BybitOrderSide,
825    #[serde(rename = "v")]
826    pub v: String,
827    #[serde(rename = "p")]
828    pub p: String,
829}
830
831/// Envelope for public liquidation updates.
832#[derive(Clone, Debug, Serialize, Deserialize)]
833#[serde(rename_all = "camelCase")]
834pub struct BybitWsLiquidationMsg {
835    pub topic: Ustr,
836    #[serde(rename = "type")]
837    pub msg_type: Ustr,
838    pub ts: i64,
839    pub data: Vec<BybitWsLiquidation>,
840}
841
842/// Private order stream payload.
843#[derive(Clone, Debug, Serialize, Deserialize)]
844#[serde(rename_all = "camelCase")]
845pub struct BybitWsAccountOrder {
846    pub category: BybitProductType,
847    pub symbol: Ustr,
848    pub order_id: Ustr,
849    pub side: BybitOrderSide,
850    pub order_type: BybitOrderType,
851    pub cancel_type: BybitCancelType,
852    pub price: String,
853    pub qty: String,
854    pub order_iv: String,
855    pub time_in_force: BybitTimeInForce,
856    pub order_status: BybitOrderStatus,
857    pub order_link_id: Ustr,
858    pub last_price_on_created: Ustr,
859    pub reduce_only: bool,
860    pub leaves_qty: String,
861    pub leaves_value: String,
862    pub cum_exec_qty: String,
863    pub cum_exec_value: String,
864    pub avg_price: String,
865    pub block_trade_id: Ustr,
866    #[serde(deserialize_with = "deserialize_i32_or_string")]
867    pub position_idx: i32,
868    pub cum_exec_fee: String,
869    pub created_time: String,
870    pub updated_time: String,
871    pub reject_reason: Ustr,
872    pub trigger_price: String,
873    pub take_profit: String,
874    pub stop_loss: String,
875    pub tp_trigger_by: BybitTriggerType,
876    pub sl_trigger_by: BybitTriggerType,
877    pub tp_limit_price: String,
878    pub sl_limit_price: String,
879    pub close_on_trigger: bool,
880    pub place_type: Ustr,
881    pub smp_type: BybitSmpType,
882    #[serde(deserialize_with = "deserialize_i32_or_string")]
883    pub smp_group: i32,
884    pub smp_order_id: Ustr,
885    pub fee_currency: Ustr,
886    pub trigger_by: BybitTriggerType,
887    pub stop_order_type: BybitStopOrderType,
888    pub trigger_direction: BybitTriggerDirection,
889    #[serde(default)]
890    pub tpsl_mode: Option<BybitTpSlMode>,
891    #[serde(default)]
892    pub create_type: Option<BybitCreateType>,
893}
894
895/// Envelope for account order updates.
896#[derive(Clone, Debug, Serialize, Deserialize)]
897#[serde(rename_all = "camelCase")]
898pub struct BybitWsAccountOrderMsg {
899    pub topic: Ustr,
900    pub id: String,
901    pub creation_time: i64,
902    pub data: Vec<BybitWsAccountOrder>,
903}
904
905/// Private execution (fill) stream payload.
906#[derive(Clone, Debug, Serialize, Deserialize)]
907#[serde(rename_all = "camelCase")]
908pub struct BybitWsAccountExecution {
909    pub category: BybitProductType,
910    pub symbol: Ustr,
911    pub exec_fee: String,
912    pub exec_id: String,
913    pub exec_price: String,
914    pub exec_qty: String,
915    pub exec_type: BybitExecType,
916    pub exec_value: String,
917    pub is_maker: bool,
918    pub fee_rate: String,
919    pub fee_currency: Ustr,
920    pub trade_iv: String,
921    pub mark_iv: String,
922    pub block_trade_id: Ustr,
923    pub mark_price: String,
924    pub index_price: String,
925    pub underlying_price: String,
926    pub leaves_qty: String,
927    pub order_id: Ustr,
928    pub order_link_id: Ustr,
929    pub order_price: String,
930    pub order_qty: String,
931    pub order_type: BybitOrderType,
932    pub side: BybitOrderSide,
933    pub exec_time: String,
934    pub is_leverage: String,
935    pub closed_size: String,
936    pub seq: i64,
937    pub stop_order_type: BybitStopOrderType,
938}
939
940/// Envelope for account execution updates.
941#[derive(Clone, Debug, Serialize, Deserialize)]
942#[serde(rename_all = "camelCase")]
943pub struct BybitWsAccountExecutionMsg {
944    pub topic: Ustr,
945    pub id: String,
946    pub creation_time: i64,
947    pub data: Vec<BybitWsAccountExecution>,
948}
949
950/// Slim execution payload delivered on the `execution.fast` private channel.
951///
952/// The fast stream omits fee/exec-type metadata that the standard `execution`
953/// channel provides; subscribe to both if you need full fill data.
954///
955/// Note: `orderLinkId` is documented as always empty for maker fills (and for
956/// option maker fills); identity correlation by `orderLinkId` works only for
957/// taker fast fills.
958///
959/// # References
960/// - <https://bybit-exchange.github.io/docs/v5/websocket/private/fast-execution>
961#[derive(Clone, Debug, Serialize, Deserialize)]
962#[serde(rename_all = "camelCase")]
963pub struct BybitWsAccountExecutionFast {
964    pub category: BybitProductType,
965    pub symbol: Ustr,
966    pub exec_id: String,
967    pub exec_price: String,
968    pub exec_qty: String,
969    pub order_id: Ustr,
970    pub order_link_id: Ustr,
971    pub side: BybitOrderSide,
972    pub exec_time: String,
973    pub is_maker: bool,
974    #[serde(default = "default_ws_execution_fast_seq")]
975    pub seq: i64,
976}
977
978const fn default_ws_execution_fast_seq() -> i64 {
979    -1
980}
981
982/// Envelope for account fast-execution updates.
983///
984/// The fast stream envelope omits the `id` field that the standard `execution`
985/// envelope includes.
986#[derive(Clone, Debug, Serialize, Deserialize)]
987#[serde(rename_all = "camelCase")]
988pub struct BybitWsAccountExecutionFastMsg {
989    pub topic: Ustr,
990    #[serde(default)]
991    pub id: String,
992    pub creation_time: i64,
993    pub data: Vec<BybitWsAccountExecutionFast>,
994}
995
996/// Coin level wallet update payload on private streams.
997#[derive(Clone, Debug, Serialize, Deserialize)]
998#[serde(rename_all = "camelCase")]
999pub struct BybitWsAccountWalletCoin {
1000    pub coin: Ustr,
1001    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
1002    pub wallet_balance: Decimal,
1003    pub available_to_withdraw: String,
1004    pub available_to_borrow: String,
1005    pub accrued_interest: String,
1006    #[serde(
1007        default,
1008        rename = "totalOrderIM",
1009        deserialize_with = "deserialize_optional_decimal_or_zero"
1010    )]
1011    pub total_order_im: Decimal,
1012    #[serde(
1013        default,
1014        rename = "totalPositionIM",
1015        deserialize_with = "deserialize_optional_decimal_or_zero"
1016    )]
1017    pub total_position_im: Decimal,
1018    #[serde(default, rename = "totalPositionMM")]
1019    pub total_position_mm: Option<String>,
1020    pub equity: String,
1021    #[serde(default, deserialize_with = "deserialize_optional_decimal_or_zero")]
1022    pub spot_borrow: Decimal,
1023}
1024
1025/// Wallet summary payload covering all coins.
1026#[derive(Clone, Debug, Serialize, Deserialize)]
1027#[serde(rename_all = "camelCase")]
1028pub struct BybitWsAccountWallet {
1029    pub total_wallet_balance: String,
1030    pub total_equity: String,
1031    pub total_available_balance: String,
1032    pub total_margin_balance: String,
1033    pub total_initial_margin: String,
1034    pub total_maintenance_margin: String,
1035    #[serde(rename = "accountIMRate")]
1036    pub account_im_rate: String,
1037    #[serde(rename = "accountMMRate")]
1038    pub account_mm_rate: String,
1039    #[serde(rename = "accountLTV")]
1040    pub account_ltv: String,
1041    pub coin: Vec<BybitWsAccountWalletCoin>,
1042}
1043
1044/// Envelope for wallet updates on private streams.
1045#[derive(Clone, Debug, Serialize, Deserialize)]
1046#[serde(rename_all = "camelCase")]
1047pub struct BybitWsAccountWalletMsg {
1048    pub topic: Ustr,
1049    pub id: String,
1050    pub creation_time: i64,
1051    pub data: Vec<BybitWsAccountWallet>,
1052}
1053
1054/// Position data from private position stream.
1055#[derive(Clone, Debug, Serialize, Deserialize)]
1056#[serde(rename_all = "camelCase")]
1057pub struct BybitWsAccountPosition {
1058    pub category: BybitProductType,
1059    pub symbol: Ustr,
1060    pub side: BybitPositionSide,
1061    pub size: String,
1062    #[serde(deserialize_with = "deserialize_i32_or_string")]
1063    pub position_idx: i32,
1064    #[serde(deserialize_with = "deserialize_i32_or_string")]
1065    pub trade_mode: i32,
1066    pub position_value: String,
1067    #[serde(deserialize_with = "deserialize_i64_or_string")]
1068    pub risk_id: i64,
1069    pub risk_limit_value: String,
1070    #[serde(deserialize_with = "deserialize_optional_decimal_str")]
1071    pub entry_price: Option<Decimal>,
1072    pub mark_price: String,
1073    pub leverage: String,
1074    pub position_balance: String,
1075    #[serde(deserialize_with = "deserialize_i32_or_string")]
1076    pub auto_add_margin: i32,
1077    #[serde(rename = "positionIM")]
1078    pub position_im: String,
1079    #[serde(rename = "positionIMByMp")]
1080    pub position_im_by_mp: String,
1081    #[serde(rename = "positionMM")]
1082    pub position_mm: String,
1083    #[serde(rename = "positionMMByMp")]
1084    pub position_mm_by_mp: String,
1085    pub liq_price: String,
1086    pub bust_price: String,
1087    pub tpsl_mode: BybitTpSlMode,
1088    pub take_profit: String,
1089    pub stop_loss: String,
1090    pub trailing_stop: String,
1091    pub unrealised_pnl: String,
1092    pub session_avg_price: String,
1093    pub cur_realised_pnl: String,
1094    pub cum_realised_pnl: String,
1095    pub position_status: BybitPositionStatus,
1096    #[serde(deserialize_with = "deserialize_i32_or_string")]
1097    pub adl_rank_indicator: i32,
1098    pub created_time: String,
1099    pub updated_time: String,
1100    #[serde(default = "default_ws_position_seq")]
1101    pub seq: i64,
1102    #[serde(default)]
1103    pub is_reduce_only: bool,
1104    #[serde(default)]
1105    pub mmr_sys_updated_time: String,
1106    #[serde(default)]
1107    pub leverage_sys_updated_time: String,
1108    #[serde(default)]
1109    pub open_time: i64,
1110}
1111
1112const fn default_ws_position_seq() -> i64 {
1113    -1
1114}
1115
1116/// Envelope for position updates on private streams.
1117#[derive(Clone, Debug, Serialize, Deserialize)]
1118#[serde(rename_all = "camelCase")]
1119pub struct BybitWsAccountPositionMsg {
1120    pub topic: Ustr,
1121    pub id: String,
1122    pub creation_time: i64,
1123    pub data: Vec<BybitWsAccountPosition>,
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use rstest::rstest;
1129
1130    use super::*;
1131
1132    #[rstest]
1133    fn auth_request_serializes_and_redacts_debug() {
1134        let request = BybitAuthRequest {
1135            op: BybitWsOperation::Auth,
1136            args: vec![
1137                serde_json::json!("api-key-value"),
1138                serde_json::json!(1_700_000_000_000_u64),
1139                serde_json::json!("signature-value"),
1140            ],
1141        };
1142
1143        let json = serde_json::to_value(&request).unwrap();
1144        let formatted = format!("{request:?}");
1145
1146        assert_eq!(json["args"][0], "api-key-value");
1147        assert_eq!(json["args"][1], 1_700_000_000_000_u64);
1148        assert_eq!(json["args"][2], "signature-value");
1149        assert!(formatted.contains(REDACTED));
1150        assert!(!formatted.contains("api-key-value"));
1151        assert!(!formatted.contains("signature-value"));
1152    }
1153    use crate::common::testing::load_test_json;
1154
1155    #[rstest]
1156    fn deserialize_account_execution_fast_msg() {
1157        // Sample payload from the venue docs: slim envelope without `id`,
1158        // includes `isMaker`, taker fill with populated `orderLinkId`.
1159        let json = load_test_json("ws_account_execution_fast.json");
1160        let msg: BybitWsAccountExecutionFastMsg = serde_json::from_str(&json).unwrap();
1161
1162        assert_eq!(msg.id, "");
1163        assert_eq!(msg.creation_time, 1_716_800_399_338);
1164        assert_eq!(msg.data.len(), 1);
1165        let exec = &msg.data[0];
1166        assert_eq!(exec.category, BybitProductType::Linear);
1167        assert_eq!(exec.symbol, Ustr::from("ICPUSDT"));
1168        assert_eq!(exec.exec_id, "3510f361-0add-5c7b-a2e7-9679810944fc");
1169        assert_eq!(exec.exec_price, "12.015");
1170        assert_eq!(exec.exec_qty, "3000");
1171        assert_eq!(
1172            exec.order_id,
1173            Ustr::from("443d63fa-b4c3-4297-b7b1-23bca88b04dc")
1174        );
1175        assert_eq!(exec.order_link_id, Ustr::from("test-order-link-001"));
1176        assert_eq!(exec.side, BybitOrderSide::Sell);
1177        assert!(!exec.is_maker);
1178        assert_eq!(exec.exec_time, "1716800399334");
1179        assert_eq!(exec.seq, 34_771_365_464);
1180    }
1181
1182    #[rstest]
1183    fn deserialize_account_execution_fast_msg_accepts_envelope_id() {
1184        // Forward-compat: if the venue adds an envelope `id` later, it must still parse.
1185        let json = load_test_json("ws_account_execution_fast_envelope_id.json");
1186        let msg: BybitWsAccountExecutionFastMsg = serde_json::from_str(&json).unwrap();
1187        assert_eq!(msg.id, "fast-1");
1188        assert!(msg.data.is_empty());
1189    }
1190
1191    #[rstest]
1192    fn deserialize_account_position_with_open_time() {
1193        let json = load_test_json("ws_account_position_with_open_time.json");
1194        let position: BybitWsAccountPosition = serde_json::from_str(&json).unwrap();
1195        assert_eq!(position.open_time, 1_700_000_000_123);
1196    }
1197
1198    #[rstest]
1199    fn serialize_place_params_includes_order_iv_when_set() {
1200        let params = BybitWsPlaceOrderParams {
1201            category: BybitProductType::Option,
1202            symbol: Ustr::from("BTC-30JUN25-100000-C"),
1203            side: BybitOrderSide::Buy,
1204            order_type: BybitOrderType::Limit,
1205            qty: "0.1".to_string(),
1206            is_leverage: None,
1207            market_unit: None,
1208            price: Some("500".to_string()),
1209            time_in_force: Some(BybitTimeInForce::Gtc),
1210            order_link_id: Some("test-1".to_string()),
1211            reduce_only: None,
1212            close_on_trigger: None,
1213            trigger_price: None,
1214            trigger_by: None,
1215            trigger_direction: None,
1216            tpsl_mode: None,
1217            take_profit: None,
1218            stop_loss: None,
1219            tp_trigger_by: None,
1220            sl_trigger_by: None,
1221            sl_trigger_price: None,
1222            tp_trigger_price: None,
1223            sl_order_type: None,
1224            tp_order_type: None,
1225            sl_limit_price: None,
1226            tp_limit_price: None,
1227            order_iv: Some("0.80".to_string()),
1228            smp_type: None,
1229            mmp: Some(true),
1230            position_idx: None,
1231            bbo_side_type: None,
1232            bbo_level: None,
1233        };
1234
1235        let json = serde_json::to_string(&params).unwrap();
1236        assert!(json.contains("\"orderIv\":\"0.80\""));
1237        assert!(json.contains("\"mmp\":true"));
1238    }
1239
1240    #[rstest]
1241    fn serialize_place_params_omits_order_iv_when_none() {
1242        let params = BybitWsPlaceOrderParams {
1243            category: BybitProductType::Linear,
1244            symbol: Ustr::from("BTCUSDT"),
1245            side: BybitOrderSide::Buy,
1246            order_type: BybitOrderType::Limit,
1247            qty: "0.01".to_string(),
1248            is_leverage: None,
1249            market_unit: None,
1250            price: Some("50000".to_string()),
1251            time_in_force: Some(BybitTimeInForce::Gtc),
1252            order_link_id: None,
1253            reduce_only: None,
1254            close_on_trigger: None,
1255            trigger_price: None,
1256            trigger_by: None,
1257            trigger_direction: None,
1258            tpsl_mode: None,
1259            take_profit: None,
1260            stop_loss: None,
1261            tp_trigger_by: None,
1262            sl_trigger_by: None,
1263            sl_trigger_price: None,
1264            tp_trigger_price: None,
1265            sl_order_type: None,
1266            tp_order_type: None,
1267            sl_limit_price: None,
1268            tp_limit_price: None,
1269            order_iv: None,
1270            smp_type: None,
1271            mmp: None,
1272            position_idx: None,
1273            bbo_side_type: None,
1274            bbo_level: None,
1275        };
1276
1277        let json = serde_json::to_string(&params).unwrap();
1278        assert!(!json.contains("orderIv"));
1279        assert!(!json.contains("smpType"));
1280        assert!(!json.contains("mmp"));
1281        assert!(!json.contains("positionIdx"));
1282    }
1283
1284    #[rstest]
1285    #[case(BybitOrderSmpType::None, "None")]
1286    #[case(BybitOrderSmpType::CancelMaker, "CancelMaker")]
1287    #[case(BybitOrderSmpType::CancelTaker, "CancelTaker")]
1288    #[case(BybitOrderSmpType::CancelBoth, "CancelBoth")]
1289    fn serialize_place_params_includes_smp_type_when_set(
1290        #[case] smp_type: BybitOrderSmpType,
1291        #[case] expected: &str,
1292    ) {
1293        let params = BybitWsPlaceOrderParams {
1294            category: BybitProductType::Linear,
1295            symbol: Ustr::from("BTCUSDT"),
1296            side: BybitOrderSide::Buy,
1297            order_type: BybitOrderType::Limit,
1298            qty: "0.01".to_string(),
1299            is_leverage: None,
1300            market_unit: None,
1301            price: Some("50000".to_string()),
1302            time_in_force: Some(BybitTimeInForce::Gtc),
1303            order_link_id: Some("smp-1".to_string()),
1304            reduce_only: None,
1305            close_on_trigger: None,
1306            trigger_price: None,
1307            trigger_by: None,
1308            trigger_direction: None,
1309            tpsl_mode: None,
1310            take_profit: None,
1311            stop_loss: None,
1312            tp_trigger_by: None,
1313            sl_trigger_by: None,
1314            sl_trigger_price: None,
1315            tp_trigger_price: None,
1316            sl_order_type: None,
1317            tp_order_type: None,
1318            sl_limit_price: None,
1319            tp_limit_price: None,
1320            order_iv: None,
1321            smp_type: Some(smp_type),
1322            mmp: None,
1323            position_idx: None,
1324            bbo_side_type: None,
1325            bbo_level: None,
1326        };
1327
1328        let json: serde_json::Value = serde_json::to_value(&params).unwrap();
1329
1330        assert_eq!(json.get("smpType").and_then(Value::as_str), Some(expected));
1331    }
1332
1333    #[rstest]
1334    fn serialize_place_params_includes_bbo_when_set() {
1335        let params = BybitWsPlaceOrderParams {
1336            category: BybitProductType::Linear,
1337            symbol: Ustr::from("BTCUSDT"),
1338            side: BybitOrderSide::Buy,
1339            order_type: BybitOrderType::Limit,
1340            qty: "0.01".to_string(),
1341            is_leverage: None,
1342            market_unit: None,
1343            price: None,
1344            time_in_force: Some(BybitTimeInForce::Gtc),
1345            order_link_id: None,
1346            reduce_only: None,
1347            close_on_trigger: None,
1348            trigger_price: None,
1349            trigger_by: None,
1350            trigger_direction: None,
1351            tpsl_mode: None,
1352            take_profit: None,
1353            stop_loss: None,
1354            tp_trigger_by: None,
1355            sl_trigger_by: None,
1356            sl_trigger_price: None,
1357            tp_trigger_price: None,
1358            sl_order_type: None,
1359            tp_order_type: None,
1360            sl_limit_price: None,
1361            tp_limit_price: None,
1362            order_iv: None,
1363            smp_type: None,
1364            mmp: None,
1365            position_idx: None,
1366            bbo_side_type: Some(BybitBboSideType::Queue),
1367            bbo_level: Some("2".to_string()),
1368        };
1369
1370        let json = serde_json::to_string(&params).unwrap();
1371        assert!(json.contains("\"bboSideType\":\"Queue\""));
1372        assert!(json.contains("\"bboLevel\":\"2\""));
1373        assert!(!json.contains("\"price\""));
1374    }
1375
1376    #[rstest]
1377    #[case(BybitPositionIdx::BuyHedge, 1)]
1378    #[case(BybitPositionIdx::SellHedge, 2)]
1379    fn serialize_place_params_includes_position_idx_when_set(
1380        #[case] idx: BybitPositionIdx,
1381        #[case] expected: i32,
1382    ) {
1383        let params = BybitWsPlaceOrderParams {
1384            category: BybitProductType::Linear,
1385            symbol: Ustr::from("BTCUSDT"),
1386            side: BybitOrderSide::Buy,
1387            order_type: BybitOrderType::Limit,
1388            qty: "0.01".to_string(),
1389            is_leverage: None,
1390            market_unit: None,
1391            price: Some("50000".to_string()),
1392            time_in_force: Some(BybitTimeInForce::Gtc),
1393            order_link_id: None,
1394            reduce_only: None,
1395            close_on_trigger: None,
1396            trigger_price: None,
1397            trigger_by: None,
1398            trigger_direction: None,
1399            tpsl_mode: None,
1400            take_profit: None,
1401            stop_loss: None,
1402            tp_trigger_by: None,
1403            sl_trigger_by: None,
1404            sl_trigger_price: None,
1405            tp_trigger_price: None,
1406            sl_order_type: None,
1407            tp_order_type: None,
1408            sl_limit_price: None,
1409            tp_limit_price: None,
1410            order_iv: None,
1411            smp_type: None,
1412            mmp: None,
1413            position_idx: Some(idx),
1414            bbo_side_type: None,
1415            bbo_level: None,
1416        };
1417
1418        let json = serde_json::to_string(&params).unwrap();
1419        assert!(json.contains(&format!("\"positionIdx\":{expected}")));
1420    }
1421
1422    #[rstest]
1423    #[case(None)]
1424    #[case(Some(BybitPositionIdx::OneWay))]
1425    #[case(Some(BybitPositionIdx::BuyHedge))]
1426    #[case(Some(BybitPositionIdx::SellHedge))]
1427    fn place_params_position_idx_roundtrip(#[case] idx: Option<BybitPositionIdx>) {
1428        let params = BybitWsPlaceOrderParams {
1429            category: BybitProductType::Linear,
1430            symbol: Ustr::from("BTCUSDT"),
1431            side: BybitOrderSide::Buy,
1432            order_type: BybitOrderType::Limit,
1433            qty: "0.01".to_string(),
1434            is_leverage: None,
1435            market_unit: None,
1436            price: Some("50000".to_string()),
1437            time_in_force: Some(BybitTimeInForce::Gtc),
1438            order_link_id: None,
1439            reduce_only: None,
1440            close_on_trigger: None,
1441            trigger_price: None,
1442            trigger_by: None,
1443            trigger_direction: None,
1444            tpsl_mode: None,
1445            take_profit: None,
1446            stop_loss: None,
1447            tp_trigger_by: None,
1448            sl_trigger_by: None,
1449            sl_trigger_price: None,
1450            tp_trigger_price: None,
1451            sl_order_type: None,
1452            tp_order_type: None,
1453            sl_limit_price: None,
1454            tp_limit_price: None,
1455            order_iv: None,
1456            smp_type: None,
1457            mmp: None,
1458            position_idx: idx,
1459            bbo_side_type: None,
1460            bbo_level: None,
1461        };
1462
1463        let json = serde_json::to_string(&params).unwrap();
1464        let decoded: BybitWsPlaceOrderParams = serde_json::from_str(&json).unwrap();
1465        assert_eq!(decoded.position_idx, idx);
1466    }
1467
1468    #[rstest]
1469    fn serialize_amend_params_includes_order_iv_when_set() {
1470        let params = BybitWsAmendOrderParams {
1471            category: BybitProductType::Option,
1472            symbol: Ustr::from("BTC-30JUN25-100000-C"),
1473            order_id: None,
1474            order_link_id: Some("test-1".to_string()),
1475            qty: None,
1476            price: None,
1477            trigger_price: None,
1478            take_profit: None,
1479            stop_loss: None,
1480            tp_trigger_by: None,
1481            sl_trigger_by: None,
1482            order_iv: Some("0.90".to_string()),
1483        };
1484
1485        let json = serde_json::to_string(&params).unwrap();
1486        assert!(json.contains("\"orderIv\":\"0.90\""));
1487    }
1488
1489    #[rstest]
1490    fn deserialize_account_order_frame_uses_enums() {
1491        let json = load_test_json("ws_account_order.json");
1492        let frame: BybitWsAccountOrderMsg = serde_json::from_str(&json).unwrap();
1493        let order = &frame.data[0];
1494
1495        assert_eq!(order.cancel_type, BybitCancelType::CancelByUser);
1496        assert_eq!(order.tp_trigger_by, BybitTriggerType::MarkPrice);
1497        assert_eq!(order.sl_trigger_by, BybitTriggerType::LastPrice);
1498        assert_eq!(order.tpsl_mode, Some(BybitTpSlMode::Full));
1499        assert_eq!(order.create_type, Some(BybitCreateType::CreateByUser));
1500        assert_eq!(order.side, BybitOrderSide::Buy);
1501        assert_eq!(order.smp_group, 0);
1502    }
1503
1504    #[rstest]
1505    fn deserialize_account_order_frame_accepts_string_smp_group() {
1506        let mut json: Value =
1507            serde_json::from_str(&load_test_json("ws_account_order.json")).unwrap();
1508        json["data"][0]["smpGroup"] = Value::String("123456789".to_string());
1509
1510        let frame: BybitWsAccountOrderMsg = serde_json::from_value(json).unwrap();
1511
1512        assert_eq!(frame.data[0].smp_group, 123_456_789);
1513    }
1514
1515    #[rstest]
1516    fn deserialize_account_order_frame_accepts_string_position_idx() {
1517        let mut json: Value =
1518            serde_json::from_str(&load_test_json("ws_account_order.json")).unwrap();
1519        json["data"][0]["positionIdx"] = Value::String("1".to_string());
1520
1521        let frame: BybitWsAccountOrderMsg = serde_json::from_value(json).unwrap();
1522
1523        assert_eq!(frame.data[0].position_idx, 1);
1524    }
1525
1526    #[rstest]
1527    fn deserialize_account_position_frame_accepts_string_integer_fields() {
1528        let mut json: Value =
1529            serde_json::from_str(&load_test_json("ws_account_position.json")).unwrap();
1530        let position = &mut json["data"][0];
1531        position["positionIdx"] = Value::String("2".to_string());
1532        position["tradeMode"] = Value::String("1".to_string());
1533        position["autoAddMargin"] = Value::String("3".to_string());
1534        position["riskId"] = Value::String("1234".to_string());
1535        position["adlRankIndicator"] = Value::String("35".to_string());
1536
1537        let frame: BybitWsAccountPositionMsg = serde_json::from_value(json).unwrap();
1538
1539        let position = &frame.data[0];
1540        assert_eq!(position.position_idx, 2);
1541        assert_eq!(position.trade_mode, 1);
1542        assert_eq!(position.auto_add_margin, 3);
1543        assert_eq!(position.risk_id, 1234);
1544        assert_eq!(position.adl_rank_indicator, 35);
1545    }
1546
1547    #[rstest]
1548    fn deserialize_ws_account_position_without_conditional_fields() {
1549        // Bybit v5 docs mark `isReduceOnly`, `mmrSysUpdatedTime`, `leverageSysUpdatedTime`
1550        // and `seq` as conditional fields that may be absent from position snapshots,
1551        // e.g. once a position has been closed through the UI (see issue #3836).
1552        let json = r#"{
1553            "topic": "position",
1554            "id": "1",
1555            "creationTime": 1697673900000,
1556            "data": [{
1557                "category": "linear",
1558                "symbol": "LTCUSDT",
1559                "side": "",
1560                "size": "0",
1561                "positionIdx": 0,
1562                "tradeMode": 0,
1563                "positionValue": "0",
1564                "riskId": 1,
1565                "riskLimitValue": "150",
1566                "entryPrice": "",
1567                "markPrice": "70.00",
1568                "leverage": "10",
1569                "positionBalance": "0",
1570                "autoAddMargin": 0,
1571                "positionIM": "0",
1572                "positionIMByMp": "0",
1573                "positionMM": "0",
1574                "positionMMByMp": "0",
1575                "liqPrice": "",
1576                "bustPrice": "",
1577                "tpslMode": "Full",
1578                "takeProfit": "0",
1579                "stopLoss": "0",
1580                "trailingStop": "0",
1581                "unrealisedPnl": "0",
1582                "sessionAvgPrice": "0",
1583                "curRealisedPnl": "0",
1584                "cumRealisedPnl": "0",
1585                "positionStatus": "Normal",
1586                "adlRankIndicator": 0,
1587                "createdTime": "1676538056258",
1588                "updatedTime": "1697673600012"
1589            }]
1590        }"#;
1591
1592        let msg: BybitWsAccountPositionMsg = serde_json::from_str(json)
1593            .expect("Failed to parse WS account position with missing conditional fields");
1594        let position = &msg.data[0];
1595
1596        assert!(!position.is_reduce_only);
1597        assert_eq!(position.seq, -1);
1598        assert_eq!(position.mmr_sys_updated_time, "");
1599        assert_eq!(position.leverage_sys_updated_time, "");
1600    }
1601}