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