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