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