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