Skip to main content

nautilus_okx/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 not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Data structures modelling OKX WebSocket request and response payloads.
17
18use derive_builder::Builder;
19use nautilus_model::{
20    data::{Data, FundingRateUpdate, InstrumentStatus, OrderBookDeltas},
21    events::{
22        AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderExpired,
23        OrderModifyRejected, OrderRejected, OrderTriggered, OrderUpdated,
24    },
25    identifiers::ClientOrderId,
26    instruments::InstrumentAny,
27    reports::{FillReport, OrderStatusReport, PositionStatusReport},
28};
29use serde::{Deserialize, Serialize};
30use ustr::Ustr;
31
32use super::enums::{OKXWsChannel, OKXWsOperation};
33use crate::{
34    common::{
35        enums::{
36            OKXAlgoOrderStatus, OKXAlgoOrderType, OKXBookAction, OKXCandleConfirm, OKXExecType,
37            OKXInstrumentType, OKXOrderCategory, OKXOrderStatus, OKXOrderType, OKXPositionSide,
38            OKXPriceType, OKXQuickMarginType, OKXSelfTradePreventionMode, OKXSettlementState,
39            OKXSide, OKXTargetCurrency, OKXTradeMode, OKXTriggerType,
40        },
41        models::OKXInstrument,
42        parse::{
43            deserialize_empty_string_as_none, deserialize_empty_ustr_as_none,
44            deserialize_string_to_u64, deserialize_target_currency_as_none,
45        },
46    },
47    http::models::OKXSpreadOrder,
48    websocket::enums::OKXSubscriptionEvent,
49};
50
51#[derive(Debug, Clone)]
52pub enum NautilusWsMessage {
53    Data(Vec<Data>),
54    Deltas(OrderBookDeltas),
55    FundingRates(Vec<FundingRateUpdate>),
56    Instrument(Box<InstrumentAny>, Option<InstrumentStatus>),
57    InstrumentStatus(InstrumentStatus),
58    AccountUpdate(AccountState),
59    PositionUpdate(PositionStatusReport),
60    OrderAccepted(OrderAccepted),
61    OrderCanceled(OrderCanceled),
62    OrderExpired(OrderExpired),
63    OrderRejected(OrderRejected),
64    OrderCancelRejected(OrderCancelRejected),
65    OrderModifyRejected(OrderModifyRejected),
66    OrderTriggered(OrderTriggered),
67    OrderUpdated(OrderUpdated),
68    ExecutionReports(Vec<ExecutionReport>),
69    Error(OKXWebSocketError),
70    Raw(serde_json::Value), // Unhandled channels
71    Reconnected,
72    Authenticated,
73}
74
75/// Represents an OKX WebSocket error.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[cfg_attr(feature = "python", pyo3::pyclass(from_py_object))]
78#[cfg_attr(
79    feature = "python",
80    pyo3_stub_gen::derive::gen_stub_pyclass(module = "nautilus_trader.adapters.okx")
81)]
82pub struct OKXWebSocketError {
83    /// Error code from OKX (e.g., "50101").
84    pub code: String,
85    /// Error message from OKX.
86    pub message: String,
87    /// Connection ID if available.
88    pub conn_id: Option<String>,
89    /// Timestamp when the error occurred.
90    pub timestamp: u64,
91}
92
93#[derive(Debug, Clone)]
94#[expect(clippy::large_enum_variant)]
95pub enum ExecutionReport {
96    Order(OrderStatusReport),
97    Fill(FillReport),
98}
99
100/// Output from the OKX WebSocket handler.
101///
102/// Contains venue-specific types only. Data parsing occurs in `PyOKXWebSocketClient`
103/// (using an instruments cache), and execution parsing occurs in `execution.rs`
104/// (using the system Cache for order lookups).
105#[derive(Debug)]
106pub enum OKXWsMessage {
107    /// Order book snapshot or update.
108    BookData {
109        arg: OKXWebSocketArg,
110        action: OKXBookAction,
111        data: Vec<OKXBookMsg>,
112    },
113    /// Data from a non-book channel (trades, tickers, mark price, funding, candles, etc.).
114    ChannelData {
115        channel: OKXWsChannel,
116        inst_id: Option<Ustr>,
117        data: serde_json::Value,
118    },
119    /// Response to a WebSocket order command (place, cancel, amend, mass-cancel).
120    OrderResponse {
121        id: Option<String>,
122        op: OKXWsOperation,
123        code: String,
124        msg: String,
125        data: Vec<serde_json::Value>,
126    },
127    /// Order push channel updates.
128    Orders(Vec<OKXOrderMsg>),
129    /// Nitro spread order push channel updates.
130    SpreadOrders(Vec<OKXSpreadOrder>),
131    /// Algo order push channel updates.
132    AlgoOrders(Vec<OKXAlgoOrderMsg>),
133    /// Account channel update (raw JSON).
134    Account(serde_json::Value),
135    /// Positions channel update (raw JSON).
136    Positions(serde_json::Value),
137    /// Instrument definition updates.
138    Instruments(Vec<OKXInstrument>),
139    /// A WebSocket send failed without a structured venue response.
140    SendFailed {
141        request_id: String,
142        client_order_id: Option<ClientOrderId>,
143        op: Option<OKXWsOperation>,
144        error: String,
145    },
146    /// Error received from OKX.
147    Error(OKXWebSocketError),
148    /// WebSocket reconnected.
149    Reconnected,
150    /// WebSocket authenticated.
151    Authenticated,
152}
153
154/// Generic WebSocket request for OKX trading commands.
155#[derive(Debug, Serialize)]
156#[serde(rename_all = "camelCase")]
157pub struct OKXWsRequest<T> {
158    /// Client request ID (required for order operations).
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub id: Option<String>,
161    /// Operation type (order, cancel-order, amend-order).
162    pub op: OKXWsOperation,
163    /// Request effective deadline. Unix timestamp format in milliseconds.
164    /// This is when the request itself expires, not related to order expiration.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub exp_time: Option<String>,
167    /// Arguments payload for the operation.
168    pub args: Vec<T>,
169}
170
171/// OKX WebSocket authentication message.
172#[derive(Debug, Serialize)]
173pub struct OKXAuthentication {
174    pub op: &'static str,
175    pub args: Vec<OKXAuthenticationArg>,
176}
177
178/// OKX WebSocket authentication arguments.
179#[derive(Debug, Serialize)]
180#[serde(rename_all = "camelCase")]
181pub struct OKXAuthenticationArg {
182    pub api_key: String,
183    pub passphrase: String,
184    pub timestamp: String,
185    pub sign: String,
186}
187
188#[derive(Debug, Serialize)]
189pub struct OKXSubscription {
190    pub op: OKXWsOperation,
191    pub args: Vec<OKXSubscriptionArg>,
192}
193
194#[derive(Clone, Debug)]
195pub struct OKXSubscriptionArg {
196    pub channel: OKXWsChannel,
197    pub inst_type: Option<OKXInstrumentType>,
198    pub inst_family: Option<Ustr>,
199    pub inst_id: Option<Ustr>,
200}
201
202impl Serialize for OKXSubscriptionArg {
203    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
204        use serde::ser::SerializeMap;
205
206        let mut map = serializer.serialize_map(None)?;
207        map.serialize_entry("channel", &self.channel)?;
208
209        if let Some(inst_type) = &self.inst_type {
210            map.serialize_entry("instType", inst_type)?;
211        }
212
213        if let Some(inst_family) = &self.inst_family {
214            map.serialize_entry("instFamily", inst_family)?;
215        }
216
217        if let Some(inst_id) = &self.inst_id {
218            let key = if self.channel.is_spread() {
219                "sprdId"
220            } else {
221                "instId"
222            };
223            map.serialize_entry(key, inst_id)?;
224        }
225
226        map.end()
227    }
228}
229
230/// OKX WebSocket message variants.
231///
232/// Uses custom deserialization that checks discriminant fields (event, op, action)
233/// to determine the correct variant.
234#[derive(Debug)]
235pub enum OKXWsFrame {
236    Login {
237        event: String,
238        code: String,
239        msg: String,
240        conn_id: String,
241    },
242    Subscription {
243        event: OKXSubscriptionEvent,
244        arg: OKXWebSocketArg,
245        conn_id: String,
246        code: Option<String>,
247        msg: Option<String>,
248    },
249    ChannelConnCount {
250        event: String,
251        channel: OKXWsChannel,
252        conn_count: String,
253        conn_id: String,
254    },
255    OrderResponse {
256        id: Option<String>,
257        op: OKXWsOperation,
258        code: String,
259        msg: String,
260        data: Vec<serde_json::Value>,
261    },
262    BookData {
263        arg: OKXWebSocketArg,
264        action: OKXBookAction,
265        data: Vec<OKXBookMsg>,
266    },
267    Data {
268        arg: OKXWebSocketArg,
269        data: serde_json::Value,
270    },
271    Error {
272        code: String,
273        msg: String,
274    },
275    Ping,
276    Reconnected,
277}
278
279impl<'de> Deserialize<'de> for OKXWsFrame {
280    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
281    where
282        D: serde::Deserializer<'de>,
283    {
284        use serde::de::Error;
285
286        // Buffer once via serde_json::Value, then take ownership of the
287        // typed subtrees with `.remove(...)` instead of `.cloned()`: the
288        // latter deep-cloned every level for L2 books and dominated the
289        // inbound decode cost.
290        let mut value = serde_json::Value::deserialize(deserializer)?;
291        let obj = value
292            .as_object_mut()
293            .ok_or_else(|| D::Error::custom("expected JSON object for OKXWsFrame"))?;
294
295        // Check discriminant fields in priority order. Discriminants stay
296        // borrowed via `.get(...).as_str()`; only the structured payloads
297        // (`arg`, `data`, `channel`, `op`, `action`) are moved out.
298
299        // 1. "event" field - Login, Subscription, ChannelConnCount, or Error
300        if let Some(event) = obj.get("event").and_then(|v| v.as_str()) {
301            match event {
302                "login" => return parse_login(obj),
303                "subscribe" | "unsubscribe" => return parse_subscription(obj),
304                "error" => return parse_error(obj),
305                _ if obj.contains_key("channel") && obj.contains_key("connCount") => {
306                    return parse_channel_conn_count(obj);
307                }
308                _ => {}
309            }
310        }
311
312        // 2. "op" field - OrderResponse
313        if obj.contains_key("op") {
314            return parse_order_response(obj);
315        }
316
317        // 3. "action" + "arg" - BookData
318        if obj.contains_key("action") && obj.contains_key("arg") {
319            return parse_book_data(obj);
320        }
321
322        // 4. "arg" + "data" without "action" - Data
323        if obj.contains_key("arg") && obj.contains_key("data") {
324            return parse_data(obj);
325        }
326
327        // 5. Fallback to Error if it has "code" and "msg"
328        if obj.contains_key("code") && obj.contains_key("msg") {
329            return parse_error(obj);
330        }
331
332        // No variant matched; no `remove` happened above, so `value` is still
333        // intact. Serialize it back into the error message to preserve the
334        // original diagnostic shape.
335        Err(D::Error::custom(format!(
336            "cannot determine OKXWsFrame variant from: {}",
337            serde_json::to_string(&value).unwrap_or_default()
338        )))
339    }
340}
341
342#[inline]
343fn take_str<E: serde::de::Error>(
344    obj: &mut serde_json::Map<String, serde_json::Value>,
345    key: &'static str,
346) -> Result<String, E> {
347    match obj.remove(key) {
348        Some(serde_json::Value::String(s)) => Ok(s),
349        Some(_) => Err(E::custom(format!("field `{key}` is not a string"))),
350        None => Err(E::missing_field(key)),
351    }
352}
353
354#[inline]
355fn take_optional_str(
356    obj: &mut serde_json::Map<String, serde_json::Value>,
357    key: &'static str,
358) -> Option<String> {
359    match obj.remove(key) {
360        Some(serde_json::Value::String(s)) => Some(s),
361        _ => None,
362    }
363}
364
365fn parse_login<E: serde::de::Error>(
366    obj: &mut serde_json::Map<String, serde_json::Value>,
367) -> Result<OKXWsFrame, E> {
368    Ok(OKXWsFrame::Login {
369        event: take_str(obj, "event")?,
370        code: take_str(obj, "code")?,
371        msg: take_str(obj, "msg")?,
372        conn_id: take_str(obj, "connId")?,
373    })
374}
375
376fn parse_subscription<E: serde::de::Error>(
377    obj: &mut serde_json::Map<String, serde_json::Value>,
378) -> Result<OKXWsFrame, E> {
379    let event_val = obj
380        .remove("event")
381        .ok_or_else(|| E::missing_field("event"))?;
382    let event: OKXSubscriptionEvent =
383        serde_json::from_value(event_val).map_err(|e| E::custom(format!("invalid event: {e}")))?;
384
385    let arg_val = obj.remove("arg").ok_or_else(|| E::missing_field("arg"))?;
386    let arg: OKXWebSocketArg =
387        serde_json::from_value(arg_val).map_err(|e| E::custom(format!("invalid arg: {e}")))?;
388
389    Ok(OKXWsFrame::Subscription {
390        event,
391        arg,
392        conn_id: take_str(obj, "connId")?,
393        code: take_optional_str(obj, "code"),
394        msg: take_optional_str(obj, "msg"),
395    })
396}
397
398fn parse_channel_conn_count<E: serde::de::Error>(
399    obj: &mut serde_json::Map<String, serde_json::Value>,
400) -> Result<OKXWsFrame, E> {
401    let channel_val = obj
402        .remove("channel")
403        .ok_or_else(|| E::missing_field("channel"))?;
404    let channel: OKXWsChannel = serde_json::from_value(channel_val)
405        .map_err(|e| E::custom(format!("invalid channel: {e}")))?;
406
407    Ok(OKXWsFrame::ChannelConnCount {
408        event: take_str(obj, "event")?,
409        channel,
410        conn_count: take_str(obj, "connCount")?,
411        conn_id: take_str(obj, "connId")?,
412    })
413}
414
415fn parse_order_response<E: serde::de::Error>(
416    obj: &mut serde_json::Map<String, serde_json::Value>,
417) -> Result<OKXWsFrame, E> {
418    let op_val = obj.remove("op").ok_or_else(|| E::missing_field("op"))?;
419    let op: OKXWsOperation =
420        serde_json::from_value(op_val).map_err(|e| E::custom(format!("invalid op: {e}")))?;
421
422    let data: Vec<serde_json::Value> = match obj.remove("data") {
423        Some(v) => {
424            serde_json::from_value(v).map_err(|e| E::custom(format!("invalid data: {e}")))?
425        }
426        None => Vec::new(),
427    };
428
429    Ok(OKXWsFrame::OrderResponse {
430        id: take_optional_str(obj, "id"),
431        op,
432        code: take_str(obj, "code")?,
433        msg: take_str(obj, "msg")?,
434        data,
435    })
436}
437
438fn parse_book_data<E: serde::de::Error>(
439    obj: &mut serde_json::Map<String, serde_json::Value>,
440) -> Result<OKXWsFrame, E> {
441    let arg_val = obj.remove("arg").ok_or_else(|| E::missing_field("arg"))?;
442    let arg: OKXWebSocketArg =
443        serde_json::from_value(arg_val).map_err(|e| E::custom(format!("invalid arg: {e}")))?;
444
445    let action_val = obj
446        .remove("action")
447        .ok_or_else(|| E::missing_field("action"))?;
448    let action: OKXBookAction = serde_json::from_value(action_val)
449        .map_err(|e| E::custom(format!("invalid action: {e}")))?;
450
451    let data_val = obj.remove("data").ok_or_else(|| E::missing_field("data"))?;
452    let data: Vec<OKXBookMsg> =
453        serde_json::from_value(data_val).map_err(|e| E::custom(format!("invalid data: {e}")))?;
454
455    Ok(OKXWsFrame::BookData { arg, action, data })
456}
457
458fn parse_data<E: serde::de::Error>(
459    obj: &mut serde_json::Map<String, serde_json::Value>,
460) -> Result<OKXWsFrame, E> {
461    let arg_val = obj.remove("arg").ok_or_else(|| E::missing_field("arg"))?;
462    let arg: OKXWebSocketArg =
463        serde_json::from_value(arg_val).map_err(|e| E::custom(format!("invalid arg: {e}")))?;
464
465    let data = obj.remove("data").ok_or_else(|| E::missing_field("data"))?;
466
467    Ok(OKXWsFrame::Data { arg, data })
468}
469
470fn parse_error<E: serde::de::Error>(
471    obj: &mut serde_json::Map<String, serde_json::Value>,
472) -> Result<OKXWsFrame, E> {
473    Ok(OKXWsFrame::Error {
474        code: take_str(obj, "code")?,
475        msg: take_str(obj, "msg")?,
476    })
477}
478
479#[derive(Debug, Serialize, Deserialize)]
480#[serde(rename_all = "camelCase")]
481pub struct OKXWebSocketArg {
482    /// Channel name that pushed the data.
483    pub channel: OKXWsChannel,
484    // Spread channels identify the instrument by `sprdId`; a spread's symbol equals
485    // its `sprdId`, and a message carries `instId` xor `sprdId`, so the alias resolves
486    // both to one field without collision.
487    #[serde(default, alias = "sprdId")]
488    pub inst_id: Option<Ustr>,
489    #[serde(default)]
490    pub inst_type: Option<OKXInstrumentType>,
491    #[serde(default)]
492    pub inst_family: Option<Ustr>,
493    #[serde(default)]
494    pub bar: Option<Ustr>,
495}
496
497/// Ticker data for an instrument.
498#[derive(Debug, Serialize, Deserialize)]
499#[serde(rename_all = "camelCase")]
500pub struct OKXTickerMsg {
501    /// Instrument type, e.g. "SPOT", "SWAP".
502    pub inst_type: OKXInstrumentType,
503    /// Instrument ID, e.g. "BTC-USDT".
504    pub inst_id: Ustr,
505    /// Last traded price.
506    #[serde(rename = "last")]
507    pub last_px: String,
508    /// Last traded size.
509    pub last_sz: String,
510    /// Best ask price.
511    pub ask_px: String,
512    /// Best ask size.
513    pub ask_sz: String,
514    /// Best bid price.
515    pub bid_px: String,
516    /// Best bid size.
517    pub bid_sz: String,
518    /// 24-hour opening price.
519    pub open24h: String,
520    /// 24-hour highest price.
521    pub high24h: String,
522    /// 24-hour lowest price.
523    pub low24h: String,
524    /// 24-hour trading volume in quote currency.
525    pub vol_ccy_24h: String,
526    /// 24-hour trading volume.
527    pub vol24h: String,
528    /// The opening price of the day (UTC 0).
529    pub sod_utc0: String,
530    /// The opening price of the day (UTC 8).
531    pub sod_utc8: String,
532    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
533    #[serde(deserialize_with = "deserialize_string_to_u64")]
534    pub ts: u64,
535    /// Order source for ELP liquidity identification.
536    #[serde(default)]
537    pub source: Option<String>,
538}
539
540/// Represents a single order in the order book.
541#[derive(Debug, Serialize, Deserialize)]
542pub struct OrderBookEntry {
543    /// Price of the order.
544    pub price: String,
545    /// Size of the order.
546    pub size: String,
547    // Spread book levels (`sprd-books5`) are 3-element `[price, size, count]`,
548    // omitting the liquidated-orders field standard books carry; default the
549    // trailing counts so both array shapes deserialize. Only price/size are used.
550    /// Number of liquidated orders.
551    #[serde(default)]
552    pub liquidated_orders_count: String,
553    /// Total number of orders at this price.
554    #[serde(default)]
555    pub orders_count: String,
556}
557
558/// Order book data for an instrument.
559#[derive(Debug, Serialize, Deserialize)]
560#[serde(rename_all = "camelCase")]
561pub struct OKXBookMsg {
562    /// Order book asks [price, size, liquidated orders count, orders count].
563    pub asks: Vec<OrderBookEntry>,
564    /// Order book bids [price, size, liquidated orders count, orders count].
565    pub bids: Vec<OrderBookEntry>,
566    /// Checksum value.
567    pub checksum: Option<i64>,
568    /// Sequence ID of the last sent message. Only applicable to books, books-l2-tbt, books50-l2-tbt.
569    pub prev_seq_id: Option<i64>,
570    /// Sequence ID of the current message, implementation details below.
571    pub seq_id: u64,
572    /// Order book generation time, Unix timestamp format in milliseconds, e.g. 1597026383085.
573    #[serde(deserialize_with = "deserialize_string_to_u64")]
574    pub ts: u64,
575}
576
577/// Trade data for an instrument.
578#[derive(Debug, Serialize, Deserialize)]
579#[serde(rename_all = "camelCase")]
580pub struct OKXTradeMsg {
581    // Spread public trades (`sprd-public-trades`) key the instrument as `sprdId`
582    // and omit `count`; the actual instrument is resolved from the channel arg, so
583    // both fields are tolerated here and unused by parsing.
584    /// Instrument ID (`instId`, or `sprdId` for spread public trades).
585    #[serde(default, alias = "sprdId")]
586    pub inst_id: Ustr,
587    /// Trade ID.
588    pub trade_id: String,
589    /// Trade price.
590    pub px: String,
591    /// Trade size.
592    pub sz: String,
593    /// Trade direction (buy or sell).
594    pub side: OKXSide,
595    /// Count (absent on spread public trades).
596    #[serde(default)]
597    pub count: String,
598    /// Trade timestamp, Unix timestamp format in milliseconds.
599    #[serde(deserialize_with = "deserialize_string_to_u64")]
600    pub ts: u64,
601    /// Order source (0: normal, 1: ELP).
602    #[serde(default)]
603    pub source: Option<String>,
604    /// Sequence ID for trade events.
605    #[serde(default)]
606    pub seq_id: Option<u64>,
607}
608
609/// Funding rate data for perpetual swaps.
610#[derive(Debug, Serialize, Deserialize)]
611#[serde(rename_all = "camelCase")]
612pub struct OKXFundingRateMsg {
613    /// Instrument type.
614    #[serde(default)]
615    pub inst_type: Option<OKXInstrumentType>,
616    /// Instrument ID.
617    pub inst_id: Ustr,
618    /// Current funding rate.
619    pub funding_rate: Ustr,
620    /// Predicted next funding rate.
621    pub next_funding_rate: Ustr,
622    /// Minimum funding rate.
623    #[serde(default)]
624    pub min_funding_rate: Option<String>,
625    /// Maximum funding rate.
626    #[serde(default)]
627    pub max_funding_rate: Option<String>,
628    /// Settlement state.
629    #[serde(default)]
630    pub sett_state: OKXSettlementState,
631    /// Settlement funding rate.
632    #[serde(default)]
633    pub sett_funding_rate: Option<String>,
634    /// Current premium.
635    #[serde(default)]
636    pub premium: Option<String>,
637    /// Funding rate calculation method.
638    #[serde(default)]
639    pub method: Option<String>,
640    /// Funding time, Unix timestamp format in milliseconds.
641    #[serde(deserialize_with = "deserialize_string_to_u64")]
642    pub funding_time: u64,
643    /// Next funding time, Unix timestamp format in milliseconds (used to determine funding interval).
644    #[serde(deserialize_with = "deserialize_string_to_u64")]
645    pub next_funding_time: u64,
646    /// Message timestamp, Unix timestamp format in milliseconds.
647    #[serde(deserialize_with = "deserialize_string_to_u64")]
648    pub ts: u64,
649}
650
651/// Mark price data for perpetual swaps.
652#[derive(Debug, Serialize, Deserialize)]
653#[serde(rename_all = "camelCase")]
654pub struct OKXMarkPriceMsg {
655    /// Instrument ID.
656    pub inst_id: Ustr,
657    /// Current mark price.
658    pub mark_px: String,
659    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
660    #[serde(deserialize_with = "deserialize_string_to_u64")]
661    pub ts: u64,
662}
663
664/// Index price data.
665#[derive(Debug, Serialize, Deserialize)]
666#[serde(rename_all = "camelCase")]
667pub struct OKXIndexPriceMsg {
668    /// Index name, e.g. "BTC-USD".
669    pub inst_id: Ustr,
670    /// Latest index price.
671    pub idx_px: String,
672    /// 24-hour highest price.
673    pub high24h: String,
674    /// 24-hour lowest price.
675    pub low24h: String,
676    /// 24-hour opening price.
677    pub open24h: String,
678    /// The opening price of the day (UTC 0).
679    pub sod_utc0: String,
680    /// The opening price of the day (UTC 8).
681    pub sod_utc8: String,
682    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
683    #[serde(deserialize_with = "deserialize_string_to_u64")]
684    pub ts: u64,
685}
686
687/// Price limit data (upper and lower limits).
688#[derive(Debug, Serialize, Deserialize)]
689#[serde(rename_all = "camelCase")]
690pub struct OKXPriceLimitMsg {
691    /// Instrument ID.
692    pub inst_id: Ustr,
693    /// Buy limit price.
694    pub buy_lmt: String,
695    /// Sell limit price.
696    pub sell_lmt: String,
697    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
698    #[serde(deserialize_with = "deserialize_string_to_u64")]
699    pub ts: u64,
700}
701
702/// Candlestick data for an instrument.
703#[derive(Debug, Serialize, Deserialize)]
704#[serde(rename_all = "camelCase")]
705pub struct OKXCandleMsg {
706    /// Candlestick timestamp, Unix timestamp format in milliseconds.
707    #[serde(deserialize_with = "deserialize_string_to_u64")]
708    pub ts: u64,
709    /// Opening price.
710    pub o: String,
711    /// Highest price.
712    pub h: String,
713    /// Lowest price.
714    pub l: String,
715    /// Closing price.
716    pub c: String,
717    /// Trading volume in contracts.
718    pub vol: String,
719    /// Trading volume in quote currency.
720    pub vol_ccy: String,
721    pub vol_ccy_quote: String,
722    /// Whether this is a completed candle.
723    pub confirm: OKXCandleConfirm,
724}
725
726/// Open interest data.
727#[derive(Debug, Serialize, Deserialize)]
728#[serde(rename_all = "camelCase")]
729pub struct OKXOpenInterestMsg {
730    /// Instrument ID.
731    pub inst_id: Ustr,
732    /// Open interest in contracts.
733    pub oi: String,
734    /// Open interest in quote currency.
735    pub oi_ccy: String,
736    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
737    #[serde(deserialize_with = "deserialize_string_to_u64")]
738    pub ts: u64,
739}
740
741/// Option market data summary.
742#[derive(Debug, Serialize, Deserialize)]
743#[serde(rename_all = "camelCase")]
744pub struct OKXOptionSummaryMsg {
745    /// Instrument type.
746    #[serde(default)]
747    pub inst_type: Option<OKXInstrumentType>,
748    /// Instrument ID.
749    pub inst_id: Ustr,
750    /// Underlying.
751    pub uly: String,
752    /// Delta.
753    pub delta: String,
754    /// Gamma.
755    pub gamma: String,
756    /// Theta.
757    pub theta: String,
758    /// Vega.
759    pub vega: String,
760    /// Black-Scholes delta.
761    #[serde(alias = "deltaBS")]
762    pub delta_bs: String,
763    /// Black-Scholes gamma.
764    #[serde(alias = "gammaBS")]
765    pub gamma_bs: String,
766    /// Black-Scholes theta.
767    #[serde(alias = "thetaBS")]
768    pub theta_bs: String,
769    /// Black-Scholes vega.
770    #[serde(alias = "vegaBS")]
771    pub vega_bs: String,
772    /// Realized volatility.
773    pub real_vol: String,
774    /// Bid volatility.
775    pub bid_vol: String,
776    /// Ask volatility.
777    pub ask_vol: String,
778    /// Mark volatility.
779    pub mark_vol: String,
780    /// Leverage.
781    pub lever: String,
782    /// Forward price.
783    #[serde(default)]
784    pub fwd_px: Option<String>,
785    /// Mark price.
786    #[serde(default)]
787    pub mark_px: Option<String>,
788    /// Volatility level.
789    #[serde(default)]
790    pub vol_lv: Option<String>,
791    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
792    #[serde(deserialize_with = "deserialize_string_to_u64")]
793    pub ts: u64,
794}
795
796/// Estimated delivery/exercise price data.
797#[derive(Debug, Serialize, Deserialize)]
798#[serde(rename_all = "camelCase")]
799pub struct OKXEstimatedPriceMsg {
800    /// Instrument ID.
801    pub inst_id: Ustr,
802    /// Estimated settlement price.
803    pub settle_px: String,
804    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
805    #[serde(deserialize_with = "deserialize_string_to_u64")]
806    pub ts: u64,
807}
808
809/// Platform status updates.
810#[derive(Debug, Serialize, Deserialize)]
811#[serde(rename_all = "camelCase")]
812pub struct OKXStatusMsg {
813    /// System maintenance status.
814    pub title: Ustr,
815    /// Status type: planned or scheduled.
816    #[serde(rename = "type")]
817    pub status_type: Ustr,
818    /// System maintenance state: canceled, completed, pending, ongoing.
819    pub state: Ustr,
820    /// Expected completion timestamp.
821    pub end_time: Option<String>,
822    /// Planned start timestamp.
823    pub begin_time: Option<String>,
824    /// Service involved.
825    pub service_type: Option<Ustr>,
826    /// Reason for status change.
827    pub reason: Option<String>,
828    /// Timestamp of the data generation, Unix timestamp format in milliseconds.
829    #[serde(deserialize_with = "deserialize_string_to_u64")]
830    pub ts: u64,
831}
832
833pub use crate::common::models::OKXAttachedAlgoOrd;
834
835/// Linked algo order metadata from order push updates.
836#[derive(Clone, Debug, Default, Serialize, Deserialize)]
837#[serde(rename_all = "camelCase")]
838pub struct OKXLinkedAlgoOrd {
839    /// Parent algo order ID.
840    #[serde(default)]
841    pub algo_id: String,
842}
843
844/// Order update message from WebSocket orders channel.
845#[derive(Clone, Debug, Serialize, Deserialize)]
846#[serde(rename_all = "camelCase")]
847pub struct OKXOrderMsg {
848    /// Accumulated filled size.
849    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
850    pub acc_fill_sz: Option<String>,
851    /// Algo order ID.
852    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
853    pub algo_id: Option<String>,
854    /// Average price.
855    pub avg_px: String,
856    /// Creation time, Unix timestamp in milliseconds.
857    #[serde(deserialize_with = "deserialize_string_to_u64")]
858    pub c_time: u64,
859    /// Cancel source.
860    #[serde(default)]
861    pub cancel_source: Option<String>,
862    /// Cancel source reason.
863    #[serde(default)]
864    pub cancel_source_reason: Option<String>,
865    /// Order category (normal, liquidation, ADL, etc.).
866    pub category: OKXOrderCategory,
867    /// Currency.
868    pub ccy: Ustr,
869    /// Client order ID.
870    pub cl_ord_id: String,
871    /// Parent algo client order ID if present.
872    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
873    pub algo_cl_ord_id: Option<String>,
874    /// Attached child client order ID if surfaced at the top level.
875    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
876    pub attach_algo_cl_ord_id: Option<String>,
877    /// Attached TP/SL child order metadata.
878    #[serde(default)]
879    pub attach_algo_ords: Vec<OKXAttachedAlgoOrd>,
880    /// Event contract market outcome, if applicable.
881    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
882    pub outcome: Option<String>,
883    /// Fee (cumulative).
884    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
885    pub fee: Option<String>,
886    /// Fee currency.
887    pub fee_ccy: Ustr,
888    /// Fee for this fill.
889    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
890    pub fill_fee: Option<String>,
891    /// Fill fee currency.
892    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
893    pub fill_fee_ccy: Option<Ustr>,
894    /// Mark price at fill time.
895    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
896    pub fill_mark_px: Option<String>,
897    /// Mark volatility at fill time (options).
898    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
899    pub fill_mark_vol: Option<String>,
900    /// Implied volatility at fill time (options).
901    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
902    pub fill_px_vol: Option<String>,
903    /// Fill price in USD (options).
904    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
905    pub fill_px_usd: Option<String>,
906    /// Forward price at fill time (options).
907    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
908    pub fill_fwd_px: Option<String>,
909    /// Fill notional in USD.
910    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
911    pub fill_notional_usd: Option<String>,
912    /// PnL for this fill.
913    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
914    pub fill_pnl: Option<String>,
915    /// Fill price.
916    pub fill_px: String,
917    /// Fill size.
918    pub fill_sz: String,
919    /// Fill time, Unix timestamp in milliseconds.
920    #[serde(deserialize_with = "deserialize_string_to_u64")]
921    pub fill_time: u64,
922    /// Instrument ID.
923    pub inst_id: Ustr,
924    /// Instrument type.
925    pub inst_type: OKXInstrumentType,
926    /// Whether the TP order is a limit order.
927    #[serde(default)]
928    pub is_tp_limit: Option<String>,
929    /// Leverage.
930    pub lever: String,
931    /// Linked algo order metadata.
932    #[serde(default)]
933    pub linked_algo_ord: Option<OKXLinkedAlgoOrd>,
934    /// Notional value in USD.
935    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
936    pub notional_usd: Option<String>,
937    /// Order ID.
938    pub ord_id: Ustr,
939    /// Order type.
940    pub ord_type: OKXOrderType,
941    /// Profit and loss.
942    pub pnl: String,
943    /// Position side.
944    pub pos_side: OKXPositionSide,
945    /// Price (algo orders use ordPx instead).
946    #[serde(default)]
947    pub px: String,
948    /// Price type (options).
949    #[serde(default)]
950    pub px_type: OKXPriceType,
951    /// Price in USD (options).
952    #[serde(default)]
953    pub px_usd: Option<String>,
954    /// Price in volatility (options).
955    #[serde(default)]
956    pub px_vol: Option<String>,
957    /// Quick margin type.
958    #[serde(default)]
959    pub quick_mgn_type: OKXQuickMarginType,
960    /// Rebate amount.
961    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
962    pub rebate: Option<String>,
963    /// Rebate currency.
964    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
965    pub rebate_ccy: Option<Ustr>,
966    /// Reduce only flag.
967    pub reduce_only: String,
968    /// Side.
969    pub side: OKXSide,
970    /// Stop-loss order price.
971    #[serde(default)]
972    pub sl_ord_px: Option<String>,
973    /// Stop-loss trigger price.
974    #[serde(default)]
975    pub sl_trigger_px: Option<String>,
976    /// Stop-loss trigger price type (last, mark, index).
977    #[serde(default)]
978    pub sl_trigger_px_type: Option<OKXTriggerType>,
979    /// Order source.
980    #[serde(default)]
981    pub source: Option<String>,
982    /// Order state.
983    pub state: OKXOrderStatus,
984    /// Self-trade prevention ID.
985    #[serde(default)]
986    pub stp_id: Option<String>,
987    /// Self-trade prevention mode.
988    #[serde(default)]
989    pub stp_mode: OKXSelfTradePreventionMode,
990    /// Execution type.
991    pub exec_type: OKXExecType,
992    /// Size.
993    pub sz: String,
994    /// Order tag.
995    #[serde(default)]
996    pub tag: Option<String>,
997    /// Trade mode.
998    pub td_mode: OKXTradeMode,
999    /// Target currency (base_ccy or quote_ccy). Empty for margin modes.
1000    #[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
1001    pub tgt_ccy: Option<OKXTargetCurrency>,
1002    /// Take-profit order price.
1003    #[serde(default)]
1004    pub tp_ord_px: Option<String>,
1005    /// Take-profit trigger price.
1006    #[serde(default)]
1007    pub tp_trigger_px: Option<String>,
1008    /// Take-profit trigger price type (last, mark, index).
1009    #[serde(default)]
1010    pub tp_trigger_px_type: Option<OKXTriggerType>,
1011    /// Trade ID.
1012    pub trade_id: String,
1013    /// Last update time, Unix timestamp in milliseconds.
1014    #[serde(deserialize_with = "deserialize_string_to_u64")]
1015    pub u_time: u64,
1016    /// Amend result code.
1017    #[serde(default)]
1018    pub amend_result: Option<String>,
1019    /// Request ID (for amend responses).
1020    #[serde(default)]
1021    pub req_id: Option<String>,
1022    /// Error code.
1023    #[serde(default)]
1024    pub code: Option<String>,
1025    /// Error message.
1026    #[serde(default)]
1027    pub msg: Option<String>,
1028}
1029
1030/// Represents an algo order message from WebSocket updates.
1031#[derive(Clone, Debug, Deserialize, Serialize)]
1032#[serde(rename_all = "camelCase")]
1033pub struct OKXAlgoOrderMsg {
1034    /// Algorithm ID.
1035    pub algo_id: String,
1036    /// Algorithm client order ID.
1037    #[serde(default)]
1038    pub algo_cl_ord_id: String,
1039    /// Client order ID (empty for algo orders until triggered).
1040    pub cl_ord_id: String,
1041    /// Order ID (empty until algo order is triggered).
1042    pub ord_id: String,
1043    /// Instrument ID.
1044    pub inst_id: Ustr,
1045    /// Instrument type.
1046    pub inst_type: OKXInstrumentType,
1047    /// Algo order type (trigger, move_order_stop, oco, iceberg, twap).
1048    pub ord_type: OKXAlgoOrderType,
1049    /// Order state.
1050    pub state: OKXAlgoOrderStatus,
1051    /// Side.
1052    pub side: OKXSide,
1053    /// Position side.
1054    pub pos_side: OKXPositionSide,
1055    /// Size.
1056    #[serde(default)]
1057    pub sz: String,
1058    /// Trigger price.
1059    #[serde(default)]
1060    pub trigger_px: String,
1061    /// Trigger price type (last, mark, index).
1062    #[serde(default)]
1063    pub trigger_px_type: OKXTriggerType,
1064    /// Stop-loss trigger price for conditional close orders.
1065    #[serde(default)]
1066    pub sl_trigger_px: String,
1067    /// Stop-loss order price for conditional close orders.
1068    #[serde(default)]
1069    pub sl_ord_px: String,
1070    /// Stop-loss trigger price type (last, mark, index).
1071    #[serde(default)]
1072    pub sl_trigger_px_type: OKXTriggerType,
1073    /// Take-profit trigger price for conditional close orders.
1074    #[serde(default)]
1075    pub tp_trigger_px: String,
1076    /// Take-profit order price for conditional close orders.
1077    #[serde(default)]
1078    pub tp_ord_px: String,
1079    /// Take-profit trigger price type (last, mark, index).
1080    #[serde(default)]
1081    pub tp_trigger_px_type: OKXTriggerType,
1082    /// Order price (-1 for market orders).
1083    #[serde(default)]
1084    pub ord_px: String,
1085    /// Trade mode.
1086    pub td_mode: OKXTradeMode,
1087    /// Leverage.
1088    pub lever: String,
1089    /// Reduce only flag.
1090    #[serde(default)]
1091    pub reduce_only: String,
1092    /// Fraction of the position to close for close-order algos.
1093    #[serde(default)]
1094    pub close_fraction: String,
1095    /// Actual filled price.
1096    #[serde(default)]
1097    pub actual_px: String,
1098    /// Actual filled size.
1099    #[serde(default)]
1100    pub actual_sz: String,
1101    /// Notional USD value.
1102    #[serde(default)]
1103    pub notional_usd: String,
1104    /// Creation time, Unix timestamp in milliseconds.
1105    #[serde(deserialize_with = "deserialize_string_to_u64")]
1106    pub c_time: u64,
1107    /// Update time, Unix timestamp in milliseconds.
1108    #[serde(deserialize_with = "deserialize_string_to_u64")]
1109    pub u_time: u64,
1110    /// Trigger time (empty until triggered).
1111    #[serde(default)]
1112    pub trigger_time: String,
1113    /// Tag.
1114    #[serde(default)]
1115    pub tag: String,
1116    /// Callback price ratio for trailing stop (e.g. "0.01" for 1%).
1117    #[serde(default)]
1118    pub callback_ratio: String,
1119    /// Callback price spread for trailing stop (absolute distance).
1120    #[serde(default)]
1121    pub callback_spread: String,
1122    /// Activation price for trailing stop.
1123    #[serde(default)]
1124    pub active_px: String,
1125    /// Currency.
1126    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
1127    pub ccy: Option<Ustr>,
1128    /// Target currency (base_ccy or quote_ccy).
1129    #[serde(default, deserialize_with = "deserialize_target_currency_as_none")]
1130    pub tgt_ccy: Option<OKXTargetCurrency>,
1131    /// Fee amount.
1132    #[serde(default)]
1133    pub fee: Option<String>,
1134    /// Fee currency.
1135    #[serde(default, deserialize_with = "deserialize_empty_ustr_as_none")]
1136    pub fee_ccy: Option<Ustr>,
1137    /// Trigger order type (fok, ioc).
1138    #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
1139    pub advance_ord_type: Option<String>,
1140}
1141
1142/// Parameters for WebSocket place order operation.
1143#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
1144#[builder(default)]
1145#[builder(setter(into, strip_option))]
1146#[serde(rename_all = "camelCase")]
1147pub struct WsAttachAlgoOrdParams {
1148    /// Attached algo client order ID.
1149    #[serde(skip_serializing_if = "Option::is_none")]
1150    pub attach_algo_cl_ord_id: Option<String>,
1151    /// Stop-loss trigger price.
1152    #[serde(skip_serializing_if = "Option::is_none")]
1153    pub sl_trigger_px: Option<String>,
1154    /// Stop-loss order price (`-1` for market).
1155    #[serde(skip_serializing_if = "Option::is_none")]
1156    pub sl_ord_px: Option<String>,
1157    /// Stop-loss trigger price type (last, mark, index).
1158    #[serde(skip_serializing_if = "Option::is_none")]
1159    pub sl_trigger_px_type: Option<OKXTriggerType>,
1160    /// Take-profit trigger price.
1161    #[serde(skip_serializing_if = "Option::is_none")]
1162    pub tp_trigger_px: Option<String>,
1163    /// Take-profit order price (`-1` for market).
1164    #[serde(skip_serializing_if = "Option::is_none")]
1165    pub tp_ord_px: Option<String>,
1166    /// Take-profit trigger price type (last, mark, index).
1167    #[serde(skip_serializing_if = "Option::is_none")]
1168    pub tp_trigger_px_type: Option<OKXTriggerType>,
1169    /// Callback ratio for attached trailing stop orders.
1170    #[serde(skip_serializing_if = "Option::is_none")]
1171    pub callback_ratio: Option<String>,
1172    /// Callback spread for attached trailing stop orders.
1173    #[serde(skip_serializing_if = "Option::is_none")]
1174    pub callback_spread: Option<String>,
1175    /// Activation price for attached trailing stop orders.
1176    #[serde(skip_serializing_if = "Option::is_none")]
1177    pub active_px: Option<String>,
1178    /// New callback ratio for amended attached trailing stop orders.
1179    #[serde(skip_serializing_if = "Option::is_none")]
1180    pub new_callback_ratio: Option<String>,
1181    /// New callback spread for amended attached trailing stop orders.
1182    #[serde(skip_serializing_if = "Option::is_none")]
1183    pub new_callback_spread: Option<String>,
1184    /// New activation price for amended attached trailing stop orders.
1185    #[serde(skip_serializing_if = "Option::is_none")]
1186    pub new_active_px: Option<String>,
1187}
1188
1189/// Parameters for WebSocket place order operation.
1190#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
1191#[builder(setter(into, strip_option))]
1192#[serde(rename_all = "camelCase")]
1193pub struct WsPostOrderParams {
1194    /// Instrument type: SPOT, MARGIN, SWAP, FUTURES, OPTION (optional for WebSocket).
1195    #[builder(default)]
1196    #[serde(skip_serializing_if = "Option::is_none")]
1197    pub inst_type: Option<OKXInstrumentType>,
1198    /// Instrument ID code (numeric). Replaced `instId` for WebSocket order operations.
1199    pub inst_id_code: u64,
1200    /// Trading mode: cash, isolated, cross.
1201    pub td_mode: OKXTradeMode,
1202    /// Margin currency (only for isolated margin).
1203    #[builder(default)]
1204    #[serde(skip_serializing_if = "Option::is_none")]
1205    pub ccy: Option<Ustr>,
1206    /// Unique client order ID.
1207    #[builder(default)]
1208    #[serde(skip_serializing_if = "Option::is_none")]
1209    pub cl_ord_id: Option<String>,
1210    /// Order side: buy or sell.
1211    pub side: OKXSide,
1212    /// Position side: long, short, net (optional).
1213    #[builder(default)]
1214    #[serde(skip_serializing_if = "Option::is_none")]
1215    pub pos_side: Option<OKXPositionSide>,
1216    /// Order type: limit, market, post_only, fok, ioc, etc.
1217    pub ord_type: OKXOrderType,
1218    /// Order size.
1219    pub sz: String,
1220    /// Order price (required for limit orders).
1221    #[builder(default)]
1222    #[serde(skip_serializing_if = "Option::is_none")]
1223    pub px: Option<String>,
1224    /// Price in USD, only applicable to options. Mutually exclusive with `px` and `px_vol`.
1225    #[builder(default)]
1226    #[serde(rename = "pxUsd", skip_serializing_if = "Option::is_none")]
1227    pub px_usd: Option<String>,
1228    /// Price in implied volatility (1 = 100%), only applicable to options.
1229    /// Mutually exclusive with `px` and `px_usd`.
1230    #[builder(default)]
1231    #[serde(rename = "pxVol", skip_serializing_if = "Option::is_none")]
1232    pub px_vol: Option<String>,
1233    /// Reduce-only flag.
1234    #[builder(default)]
1235    #[serde(skip_serializing_if = "Option::is_none")]
1236    pub reduce_only: Option<bool>,
1237    /// Whether to close the entire position.
1238    #[builder(default)]
1239    #[serde(rename = "closePosition", skip_serializing_if = "Option::is_none")]
1240    pub close_position: Option<bool>,
1241    /// Target currency for net orders.
1242    #[builder(default)]
1243    #[serde(skip_serializing_if = "Option::is_none")]
1244    pub tgt_ccy: Option<OKXTargetCurrency>,
1245    /// Order tag for categorization.
1246    #[builder(default)]
1247    #[serde(skip_serializing_if = "Option::is_none")]
1248    pub tag: Option<String>,
1249    /// Attached TP/SL orders submitted with the parent order.
1250    #[builder(default)]
1251    #[serde(skip_serializing_if = "Option::is_none")]
1252    pub attach_algo_ords: Option<Vec<WsAttachAlgoOrdParams>>,
1253    /// Event contract speed bump flag. Use "1" for non-post-only EVENTS orders.
1254    #[builder(default)]
1255    #[serde(skip_serializing_if = "Option::is_none")]
1256    pub speed_bump: Option<String>,
1257    /// Event contract market outcome: yes or no.
1258    #[builder(default)]
1259    #[serde(skip_serializing_if = "Option::is_none")]
1260    pub outcome: Option<String>,
1261    /// Slippage tolerance for market orders, expressed as a decimal fraction
1262    /// (e.g., "0.005" for 0.5%). Supported instrument/order-type scope is
1263    /// venue-controlled; rejected with `54084`/`54085` if exceeded or out of
1264    /// the venue's accepted range. See the OKX v5 docs for the current matrix.
1265    #[builder(default)]
1266    #[serde(skip_serializing_if = "Option::is_none")]
1267    pub slippage_pct: Option<String>,
1268}
1269
1270/// Parameters for WebSocket cancel order operation (instType not included).
1271#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
1272#[builder(default)]
1273#[builder(setter(into, strip_option))]
1274#[serde(rename_all = "camelCase")]
1275pub struct WsCancelOrderParams {
1276    /// Instrument ID code (numeric). Replaced `instId` for WebSocket order operations.
1277    pub inst_id_code: u64,
1278    /// Exchange-assigned order ID.
1279    #[serde(skip_serializing_if = "Option::is_none")]
1280    pub ord_id: Option<String>,
1281    /// User-assigned client order ID.
1282    #[serde(skip_serializing_if = "Option::is_none")]
1283    pub cl_ord_id: Option<String>,
1284}
1285
1286/// Parameters for WebSocket mass cancel operation.
1287#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
1288#[builder(default)]
1289#[builder(setter(into, strip_option))]
1290#[serde(rename_all = "camelCase")]
1291pub struct WsMassCancelParams {
1292    /// Instrument type.
1293    pub inst_type: OKXInstrumentType,
1294    /// Instrument family, e.g. "BTC-USD", "BTC-USDT".
1295    pub inst_family: Ustr,
1296}
1297
1298/// Parameters for WebSocket amend order operation (instType not included).
1299#[derive(Clone, Debug, Default, Deserialize, Serialize, Builder)]
1300#[builder(default)]
1301#[builder(setter(into, strip_option))]
1302#[serde(rename_all = "camelCase")]
1303pub struct WsAmendOrderParams {
1304    /// Instrument ID code (numeric). Replaced `instId` for WebSocket order operations.
1305    pub inst_id_code: u64,
1306    /// Exchange-assigned order ID (optional if using clOrdId).
1307    #[serde(skip_serializing_if = "Option::is_none")]
1308    pub ord_id: Option<String>,
1309    /// User-assigned client order ID (optional if using ordId).
1310    #[serde(skip_serializing_if = "Option::is_none")]
1311    pub cl_ord_id: Option<String>,
1312    /// New client order ID for the amended order.
1313    #[serde(skip_serializing_if = "Option::is_none")]
1314    pub new_cl_ord_id: Option<String>,
1315    /// New order price (optional).
1316    #[serde(skip_serializing_if = "Option::is_none")]
1317    pub new_px: Option<String>,
1318    /// New price in USD, only applicable to options. Must match the pricing mode used at placement.
1319    #[serde(rename = "newPxUsd", skip_serializing_if = "Option::is_none")]
1320    pub new_px_usd: Option<String>,
1321    /// New price in implied volatility, only applicable to options.
1322    /// Must match the pricing mode used at placement.
1323    #[serde(rename = "newPxVol", skip_serializing_if = "Option::is_none")]
1324    pub new_px_vol: Option<String>,
1325    /// New order size (optional).
1326    #[serde(skip_serializing_if = "Option::is_none")]
1327    pub new_sz: Option<String>,
1328    /// Event contract speed bump flag. Use "1" for non-post-only EVENTS orders.
1329    #[serde(skip_serializing_if = "Option::is_none")]
1330    pub speed_bump: Option<String>,
1331}
1332
1333/// Parameters for WebSocket algo order placement.
1334#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
1335#[builder(setter(into, strip_option))]
1336#[serde(rename_all = "camelCase")]
1337pub struct WsPostAlgoOrderParams {
1338    /// Instrument ID code (numeric). Replaced `instId` for WebSocket order operations.
1339    pub inst_id_code: u64,
1340    /// Trading mode: cash, isolated, cross.
1341    pub td_mode: OKXTradeMode,
1342    /// Order side: buy or sell.
1343    pub side: OKXSide,
1344    /// Order type: trigger (for stop orders).
1345    pub ord_type: OKXAlgoOrderType,
1346    /// Order size.
1347    pub sz: String,
1348    /// Client order ID (optional).
1349    #[builder(default)]
1350    #[serde(skip_serializing_if = "Option::is_none")]
1351    pub cl_ord_id: Option<String>,
1352    /// Position side: long, short, net (optional).
1353    #[builder(default)]
1354    #[serde(skip_serializing_if = "Option::is_none")]
1355    pub pos_side: Option<OKXPositionSide>,
1356    /// Trigger price for stop/conditional orders.
1357    #[serde(skip_serializing_if = "Option::is_none")]
1358    pub trigger_px: Option<String>,
1359    /// Trigger price type: last, index, mark.
1360    #[builder(default)]
1361    #[serde(skip_serializing_if = "Option::is_none")]
1362    pub trigger_px_type: Option<OKXTriggerType>,
1363    /// Order price (for limit orders after trigger).
1364    #[builder(default)]
1365    #[serde(skip_serializing_if = "Option::is_none")]
1366    pub order_px: Option<String>,
1367    /// Reduce-only flag.
1368    #[builder(default)]
1369    #[serde(skip_serializing_if = "Option::is_none")]
1370    pub reduce_only: Option<bool>,
1371    /// Order tag for categorization.
1372    #[builder(default)]
1373    #[serde(skip_serializing_if = "Option::is_none")]
1374    pub tag: Option<String>,
1375    /// Callback rate for trailing stop (e.g., "0.01" for 1%).
1376    #[builder(default)]
1377    #[serde(skip_serializing_if = "Option::is_none")]
1378    pub callback_ratio: Option<String>,
1379    /// Callback spread for trailing stop (fixed price distance).
1380    #[builder(default)]
1381    #[serde(skip_serializing_if = "Option::is_none")]
1382    pub callback_spread: Option<String>,
1383    /// Activation price for trailing stop.
1384    #[builder(default)]
1385    #[serde(skip_serializing_if = "Option::is_none")]
1386    pub active_px: Option<String>,
1387}
1388
1389/// Parameters for WebSocket cancel algo order operation.
1390#[derive(Clone, Debug, Deserialize, Serialize, Builder)]
1391#[builder(setter(into, strip_option))]
1392#[serde(rename_all = "camelCase")]
1393pub struct WsCancelAlgoOrderParams {
1394    /// Instrument ID code (numeric). Replaced `instId` for WebSocket order operations.
1395    pub inst_id_code: u64,
1396    /// Algo order ID.
1397    #[serde(skip_serializing_if = "Option::is_none")]
1398    pub algo_id: Option<String>,
1399    /// Client algo order ID.
1400    #[serde(skip_serializing_if = "Option::is_none")]
1401    pub algo_cl_ord_id: Option<String>,
1402}
1403
1404#[cfg(test)]
1405mod tests {
1406    use nautilus_core::time::get_atomic_clock_realtime;
1407    use rstest::rstest;
1408
1409    use super::*;
1410
1411    #[rstest]
1412    fn test_deserialize_websocket_arg() {
1413        let json_str = r#"{"channel":"instruments","instType":"SPOT"}"#;
1414
1415        let result: Result<OKXWebSocketArg, _> = serde_json::from_str(json_str);
1416        match result {
1417            Ok(arg) => {
1418                assert_eq!(arg.channel, OKXWsChannel::Instruments);
1419                assert_eq!(arg.inst_type, Some(OKXInstrumentType::Spot));
1420                assert_eq!(arg.inst_id, None);
1421            }
1422            Err(e) => {
1423                panic!("Failed to deserialize WebSocket arg: {e}");
1424            }
1425        }
1426    }
1427
1428    #[rstest]
1429    fn test_deserialize_subscribe_variant_direct() {
1430        #[derive(Debug, Deserialize)]
1431        #[serde(rename_all = "camelCase")]
1432        struct SubscribeMsg {
1433            event: String,
1434            arg: OKXWebSocketArg,
1435            conn_id: String,
1436        }
1437
1438        let json_str = r#"{"event":"subscribe","arg":{"channel":"instruments","instType":"SPOT"},"connId":"380cfa6a"}"#;
1439
1440        let result: Result<SubscribeMsg, _> = serde_json::from_str(json_str);
1441        match result {
1442            Ok(msg) => {
1443                assert_eq!(msg.event, "subscribe");
1444                assert_eq!(msg.arg.channel, OKXWsChannel::Instruments);
1445                assert_eq!(msg.conn_id, "380cfa6a");
1446            }
1447            Err(e) => {
1448                panic!("Failed to deserialize subscribe message directly: {e}");
1449            }
1450        }
1451    }
1452
1453    #[rstest]
1454    fn test_deserialize_subscribe_confirmation() {
1455        let json_str = r#"{"event":"subscribe","arg":{"channel":"instruments","instType":"SPOT"},"connId":"380cfa6a"}"#;
1456
1457        let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
1458        match result {
1459            Ok(msg) => {
1460                if let OKXWsFrame::Subscription {
1461                    event,
1462                    arg,
1463                    conn_id,
1464                    ..
1465                } = msg
1466                {
1467                    assert_eq!(event, OKXSubscriptionEvent::Subscribe);
1468                    assert_eq!(arg.channel, OKXWsChannel::Instruments);
1469                    assert_eq!(conn_id, "380cfa6a");
1470                } else {
1471                    panic!("Expected Subscribe variant, was: {msg:?}");
1472                }
1473            }
1474            Err(e) => {
1475                panic!("Failed to deserialize subscription confirmation: {e}");
1476            }
1477        }
1478    }
1479
1480    #[rstest]
1481    fn test_deserialize_subscribe_with_inst_id() {
1482        let json_str = r#"{"event":"subscribe","arg":{"channel":"candle1m","instId":"ETH-USDT"},"connId":"358602f5"}"#;
1483
1484        let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
1485        match result {
1486            Ok(msg) => {
1487                if let OKXWsFrame::Subscription {
1488                    event,
1489                    arg,
1490                    conn_id,
1491                    ..
1492                } = msg
1493                {
1494                    assert_eq!(event, OKXSubscriptionEvent::Subscribe);
1495                    assert_eq!(arg.channel, OKXWsChannel::Candle1Minute);
1496                    assert_eq!(conn_id, "358602f5");
1497                } else {
1498                    panic!("Expected Subscribe variant, was: {msg:?}");
1499                }
1500            }
1501            Err(e) => {
1502                panic!("Failed to deserialize subscription confirmation: {e}");
1503            }
1504        }
1505    }
1506
1507    #[rstest]
1508    fn test_channel_serialization_for_logging() {
1509        let channel = OKXWsChannel::Candle1Minute;
1510        let serialized = serde_json::to_string(&channel).unwrap();
1511        let cleaned = serialized.trim_matches('"').to_string();
1512        assert_eq!(cleaned, "candle1m");
1513
1514        let channel = OKXWsChannel::BboTbt;
1515        let serialized = serde_json::to_string(&channel).unwrap();
1516        let cleaned = serialized.trim_matches('"').to_string();
1517        assert_eq!(cleaned, "bbo-tbt");
1518
1519        let channel = OKXWsChannel::Trades;
1520        let serialized = serde_json::to_string(&channel).unwrap();
1521        let cleaned = serialized.trim_matches('"').to_string();
1522        assert_eq!(cleaned, "trades");
1523    }
1524
1525    #[rstest]
1526    fn test_order_response_with_enum_operation() {
1527        let json_str = r#"{"id":"req-123","op":"order","code":"0","msg":"","data":[]}"#;
1528        let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
1529        match result {
1530            Ok(OKXWsFrame::OrderResponse {
1531                id,
1532                op,
1533                code,
1534                msg,
1535                data,
1536            }) => {
1537                assert_eq!(id, Some("req-123".to_string()));
1538                assert_eq!(op, OKXWsOperation::Order);
1539                assert_eq!(code, "0");
1540                assert_eq!(msg, "");
1541                assert!(data.is_empty());
1542            }
1543            Ok(other) => panic!("Expected OrderResponse, was: {other:?}"),
1544            Err(e) => panic!("Failed to deserialize: {e}"),
1545        }
1546
1547        let json_str = r#"{"id":"cancel-456","op":"cancel-order","code":"50001","msg":"Order not found","data":[]}"#;
1548        let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
1549        match result {
1550            Ok(OKXWsFrame::OrderResponse {
1551                id,
1552                op,
1553                code,
1554                msg,
1555                data,
1556            }) => {
1557                assert_eq!(id, Some("cancel-456".to_string()));
1558                assert_eq!(op, OKXWsOperation::CancelOrder);
1559                assert_eq!(code, "50001");
1560                assert_eq!(msg, "Order not found");
1561                assert!(data.is_empty());
1562            }
1563            Ok(other) => panic!("Expected OrderResponse, was: {other:?}"),
1564            Err(e) => panic!("Failed to deserialize: {e}"),
1565        }
1566
1567        let json_str = r#"{"id":"amend-789","op":"amend-order","code":"50002","msg":"Invalid price","data":[]}"#;
1568        let result: Result<OKXWsFrame, _> = serde_json::from_str(json_str);
1569        match result {
1570            Ok(OKXWsFrame::OrderResponse {
1571                id,
1572                op,
1573                code,
1574                msg,
1575                data,
1576            }) => {
1577                assert_eq!(id, Some("amend-789".to_string()));
1578                assert_eq!(op, OKXWsOperation::AmendOrder);
1579                assert_eq!(code, "50002");
1580                assert_eq!(msg, "Invalid price");
1581                assert!(data.is_empty());
1582            }
1583            Ok(other) => panic!("Expected OrderResponse, was: {other:?}"),
1584            Err(e) => panic!("Failed to deserialize: {e}"),
1585        }
1586    }
1587
1588    #[rstest]
1589    fn test_operation_enum_serialization() {
1590        let op = OKXWsOperation::Order;
1591        let serialized = serde_json::to_string(&op).unwrap();
1592        assert_eq!(serialized, "\"order\"");
1593
1594        let op = OKXWsOperation::CancelOrder;
1595        let serialized = serde_json::to_string(&op).unwrap();
1596        assert_eq!(serialized, "\"cancel-order\"");
1597
1598        let op = OKXWsOperation::AmendOrder;
1599        let serialized = serde_json::to_string(&op).unwrap();
1600        assert_eq!(serialized, "\"amend-order\"");
1601
1602        let op = OKXWsOperation::Subscribe;
1603        let serialized = serde_json::to_string(&op).unwrap();
1604        assert_eq!(serialized, "\"subscribe\"");
1605    }
1606
1607    #[rstest]
1608    fn test_order_response_parsing() {
1609        let success_response = r#"{
1610            "id": "req-123",
1611            "op": "order",
1612            "code": "0",
1613            "msg": "",
1614            "data": [{"sMsg": "Order placed successfully"}]
1615        }"#;
1616
1617        let parsed: OKXWsFrame = serde_json::from_str(success_response).unwrap();
1618
1619        match parsed {
1620            OKXWsFrame::OrderResponse {
1621                id,
1622                op,
1623                code,
1624                msg,
1625                data,
1626            } => {
1627                assert_eq!(id, Some("req-123".to_string()));
1628                assert_eq!(op, OKXWsOperation::Order);
1629                assert_eq!(code, "0");
1630                assert_eq!(msg, "");
1631                assert_eq!(data.len(), 1);
1632            }
1633            _ => panic!("Expected OrderResponse variant"),
1634        }
1635
1636        let failure_response = r#"{
1637            "id": "req-456",
1638            "op": "cancel-order",
1639            "code": "50001",
1640            "msg": "Order not found",
1641            "data": [{"sMsg": "Order with client order ID not found"}]
1642        }"#;
1643
1644        let parsed: OKXWsFrame = serde_json::from_str(failure_response).unwrap();
1645
1646        match parsed {
1647            OKXWsFrame::OrderResponse {
1648                id,
1649                op,
1650                code,
1651                msg,
1652                data,
1653            } => {
1654                assert_eq!(id, Some("req-456".to_string()));
1655                assert_eq!(op, OKXWsOperation::CancelOrder);
1656                assert_eq!(code, "50001");
1657                assert_eq!(msg, "Order not found");
1658                assert_eq!(data.len(), 1);
1659            }
1660            _ => panic!("Expected OrderResponse variant"),
1661        }
1662    }
1663
1664    #[rstest]
1665    fn test_subscription_event_parsing() {
1666        let subscription_json = r#"{
1667            "event": "subscribe",
1668            "arg": {
1669                "channel": "tickers",
1670                "instId": "BTC-USDT"
1671            },
1672            "connId": "a4d3ae55"
1673        }"#;
1674
1675        let parsed: OKXWsFrame = serde_json::from_str(subscription_json).unwrap();
1676
1677        match parsed {
1678            OKXWsFrame::Subscription {
1679                event,
1680                arg,
1681                conn_id,
1682                ..
1683            } => {
1684                assert_eq!(
1685                    event,
1686                    crate::websocket::enums::OKXSubscriptionEvent::Subscribe
1687                );
1688                assert_eq!(arg.channel, OKXWsChannel::Tickers);
1689                assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT")));
1690                assert_eq!(conn_id, "a4d3ae55");
1691            }
1692            _ => panic!("Expected Subscription variant"),
1693        }
1694    }
1695
1696    #[rstest]
1697    fn test_login_event_parsing() {
1698        let login_success = r#"{
1699            "event": "login",
1700            "code": "0",
1701            "msg": "Login successful",
1702            "connId": "a4d3ae55"
1703        }"#;
1704
1705        let parsed: OKXWsFrame = serde_json::from_str(login_success).unwrap();
1706
1707        match parsed {
1708            OKXWsFrame::Login {
1709                event,
1710                code,
1711                msg,
1712                conn_id,
1713            } => {
1714                assert_eq!(event, "login");
1715                assert_eq!(code, "0");
1716                assert_eq!(msg, "Login successful");
1717                assert_eq!(conn_id, "a4d3ae55");
1718            }
1719            _ => panic!("Expected Login variant, was: {parsed:?}"),
1720        }
1721    }
1722
1723    #[rstest]
1724    fn test_error_event_parsing() {
1725        let error_json = r#"{
1726            "code": "60012",
1727            "msg": "Invalid request"
1728        }"#;
1729
1730        let parsed: OKXWsFrame = serde_json::from_str(error_json).unwrap();
1731
1732        match parsed {
1733            OKXWsFrame::Error { code, msg } => {
1734                assert_eq!(code, "60012");
1735                assert_eq!(msg, "Invalid request");
1736            }
1737            _ => panic!("Expected Error variant"),
1738        }
1739    }
1740
1741    #[rstest]
1742    fn test_error_event_with_event_field_parsing() {
1743        // OKX sends error events with "event":"error" field (e.g., login failures)
1744        let error_json = r#"{
1745            "event": "error",
1746            "code": "60018",
1747            "msg": "Invalid sign"
1748        }"#;
1749
1750        let parsed: OKXWsFrame = serde_json::from_str(error_json).unwrap();
1751
1752        match parsed {
1753            OKXWsFrame::Error { code, msg } => {
1754                assert_eq!(code, "60018");
1755                assert_eq!(msg, "Invalid sign");
1756            }
1757            _ => panic!("Expected Error variant, was: {parsed:?}"),
1758        }
1759    }
1760
1761    #[rstest]
1762    fn test_subscription_error_with_arg_field_parsing() {
1763        // OKX sends subscription errors with arg field (channel subscription failures)
1764        let error_json = r#"{
1765            "event": "error",
1766            "arg": {"channel": "tickers", "instId": "INVALID-INST"},
1767            "code": "60012",
1768            "msg": "Invalid request: channel not found",
1769            "connId": "a4d3ae55"
1770        }"#;
1771
1772        let parsed: OKXWsFrame = serde_json::from_str(error_json).unwrap();
1773
1774        match parsed {
1775            OKXWsFrame::Error { code, msg } => {
1776                assert_eq!(code, "60012");
1777                assert_eq!(msg, "Invalid request: channel not found");
1778            }
1779            _ => panic!("Expected Error variant, was: {parsed:?}"),
1780        }
1781    }
1782
1783    #[rstest]
1784    fn test_websocket_request_serialization() {
1785        let request = OKXWsRequest {
1786            id: Some("req-123".to_string()),
1787            op: OKXWsOperation::Order,
1788            args: vec![serde_json::json!({
1789                "instId": "BTC-USDT",
1790                "tdMode": "cash",
1791                "side": "buy",
1792                "ordType": "market",
1793                "sz": "0.1"
1794            })],
1795            exp_time: None,
1796        };
1797
1798        let serialized = serde_json::to_string(&request).unwrap();
1799        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
1800
1801        assert_eq!(parsed["id"], "req-123");
1802        assert_eq!(parsed["op"], "order");
1803        assert!(parsed["args"].is_array());
1804        assert_eq!(parsed["args"].as_array().unwrap().len(), 1);
1805    }
1806
1807    #[rstest]
1808    fn test_subscription_request_serialization() {
1809        let subscription = OKXSubscription {
1810            op: OKXWsOperation::Subscribe,
1811            args: vec![OKXSubscriptionArg {
1812                channel: OKXWsChannel::Tickers,
1813                inst_type: Some(OKXInstrumentType::Spot),
1814                inst_family: None,
1815                inst_id: Some(Ustr::from("BTC-USDT")),
1816            }],
1817        };
1818
1819        let serialized = serde_json::to_string(&subscription).unwrap();
1820        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
1821
1822        assert_eq!(parsed["op"], "subscribe");
1823        assert!(parsed["args"].is_array());
1824        assert_eq!(parsed["args"][0]["channel"], "tickers");
1825        assert_eq!(parsed["args"][0]["instType"], "SPOT");
1826        assert_eq!(parsed["args"][0]["instId"], "BTC-USDT");
1827    }
1828
1829    #[rstest]
1830    fn test_error_message_extraction() {
1831        let responses = vec![
1832            (
1833                r#"{
1834                "id": "req-123",
1835                "op": "order",
1836                "code": "50001",
1837                "msg": "Order failed",
1838                "data": [{"sMsg": "Insufficient balance"}]
1839            }"#,
1840                "Insufficient balance",
1841            ),
1842            (
1843                r#"{
1844                "id": "req-456",
1845                "op": "cancel-order",
1846                "code": "50002",
1847                "msg": "Cancel failed",
1848                "data": [{}]
1849            }"#,
1850                "Cancel failed",
1851            ),
1852        ];
1853
1854        for (response_json, expected_msg) in responses {
1855            let parsed: OKXWsFrame = serde_json::from_str(response_json).unwrap();
1856
1857            match parsed {
1858                OKXWsFrame::OrderResponse {
1859                    id: _,
1860                    op: _,
1861                    code,
1862                    msg,
1863                    data,
1864                } => {
1865                    assert_ne!(code, "0"); // Error response
1866
1867                    // Extract error message with fallback logic
1868                    let error_msg = data
1869                        .first()
1870                        .and_then(|d| d.get("sMsg"))
1871                        .and_then(|s| s.as_str())
1872                        .filter(|s| !s.is_empty())
1873                        .unwrap_or(&msg);
1874
1875                    assert_eq!(error_msg, expected_msg);
1876                }
1877                _ => panic!("Expected OrderResponse variant"),
1878            }
1879        }
1880    }
1881
1882    #[rstest]
1883    fn test_book_data_parsing() {
1884        let book_data_json = r#"{
1885            "arg": {
1886                "channel": "books",
1887                "instId": "BTC-USDT"
1888            },
1889            "action": "snapshot",
1890            "data": [{
1891                "asks": [["50000.0", "0.1", "0", "1"]],
1892                "bids": [["49999.0", "0.2", "0", "1"]],
1893                "ts": "1640995200000",
1894                "checksum": 123456789,
1895                "seqId": 1000
1896            }]
1897        }"#;
1898
1899        let parsed: OKXWsFrame = serde_json::from_str(book_data_json).unwrap();
1900
1901        match parsed {
1902            OKXWsFrame::BookData { arg, action, data } => {
1903                assert_eq!(arg.channel, OKXWsChannel::Books);
1904                assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT")));
1905                assert_eq!(
1906                    action,
1907                    super::super::super::common::enums::OKXBookAction::Snapshot
1908                );
1909                assert_eq!(data.len(), 1);
1910            }
1911            _ => panic!("Expected BookData variant"),
1912        }
1913    }
1914
1915    #[rstest]
1916    fn test_data_event_parsing() {
1917        let data_json = r#"{
1918            "arg": {
1919                "channel": "trades",
1920                "instId": "BTC-USDT"
1921            },
1922            "data": [{
1923                "instId": "BTC-USDT",
1924                "tradeId": "12345",
1925                "px": "50000.0",
1926                "sz": "0.1",
1927                "side": "buy",
1928                "ts": "1640995200000"
1929            }]
1930        }"#;
1931
1932        let parsed: OKXWsFrame = serde_json::from_str(data_json).unwrap();
1933
1934        match parsed {
1935            OKXWsFrame::Data { arg, data } => {
1936                assert_eq!(arg.channel, OKXWsChannel::Trades);
1937                assert_eq!(arg.inst_id, Some(Ustr::from("BTC-USDT")));
1938                assert!(data.is_array());
1939            }
1940            _ => panic!("Expected Data variant"),
1941        }
1942    }
1943
1944    #[rstest]
1945    fn test_nautilus_message_variants() {
1946        let clock = get_atomic_clock_realtime();
1947        let ts_init = clock.get_time_ns();
1948
1949        let error = OKXWebSocketError {
1950            code: "60012".to_string(),
1951            message: "Invalid request".to_string(),
1952            conn_id: None,
1953            timestamp: ts_init.as_u64(),
1954        };
1955        let error_msg = NautilusWsMessage::Error(error);
1956
1957        match error_msg {
1958            NautilusWsMessage::Error(e) => {
1959                assert_eq!(e.code, "60012");
1960                assert_eq!(e.message, "Invalid request");
1961            }
1962            _ => panic!("Expected Error variant"),
1963        }
1964
1965        let raw_scenarios = vec![
1966            ::serde_json::json!({"unknown": "data"}),
1967            ::serde_json::json!({"channel": "unsupported", "data": [1, 2, 3]}),
1968            ::serde_json::json!({"complex": {"nested": {"structure": true}}}),
1969        ];
1970
1971        for raw_data in raw_scenarios {
1972            let raw_msg = NautilusWsMessage::Raw(raw_data.clone());
1973
1974            match raw_msg {
1975                NautilusWsMessage::Raw(data) => {
1976                    assert_eq!(data, raw_data);
1977                }
1978                _ => panic!("Expected Raw variant"),
1979            }
1980        }
1981    }
1982
1983    #[rstest]
1984    fn test_order_response_parsing_success() {
1985        let order_response_json = r#"{
1986            "id": "req-123",
1987            "op": "order",
1988            "code": "0",
1989            "msg": "",
1990            "data": [{"sMsg": "Order placed successfully"}]
1991        }"#;
1992
1993        let parsed: OKXWsFrame = serde_json::from_str(order_response_json).unwrap();
1994
1995        match parsed {
1996            OKXWsFrame::OrderResponse {
1997                id,
1998                op,
1999                code,
2000                msg,
2001                data,
2002            } => {
2003                assert_eq!(id, Some("req-123".to_string()));
2004                assert_eq!(op, OKXWsOperation::Order);
2005                assert_eq!(code, "0");
2006                assert_eq!(msg, "");
2007                assert_eq!(data.len(), 1);
2008            }
2009            _ => panic!("Expected OrderResponse variant"),
2010        }
2011    }
2012
2013    #[rstest]
2014    fn test_order_response_parsing_failure() {
2015        let order_response_json = r#"{
2016            "id": "req-456",
2017            "op": "cancel-order",
2018            "code": "50001",
2019            "msg": "Order not found",
2020            "data": [{"sMsg": "Order with client order ID not found"}]
2021        }"#;
2022
2023        let parsed: OKXWsFrame = serde_json::from_str(order_response_json).unwrap();
2024
2025        match parsed {
2026            OKXWsFrame::OrderResponse {
2027                id,
2028                op,
2029                code,
2030                msg,
2031                data,
2032            } => {
2033                assert_eq!(id, Some("req-456".to_string()));
2034                assert_eq!(op, OKXWsOperation::CancelOrder);
2035                assert_eq!(code, "50001");
2036                assert_eq!(msg, "Order not found");
2037                assert_eq!(data.len(), 1);
2038            }
2039            _ => panic!("Expected OrderResponse variant"),
2040        }
2041    }
2042
2043    #[rstest]
2044    fn test_message_request_serialization() {
2045        let request = OKXWsRequest {
2046            id: Some("req-123".to_string()),
2047            op: OKXWsOperation::Order,
2048            args: vec![::serde_json::json!({
2049                "instId": "BTC-USDT",
2050                "tdMode": "cash",
2051                "side": "buy",
2052                "ordType": "market",
2053                "sz": "0.1"
2054            })],
2055            exp_time: None,
2056        };
2057
2058        let serialized = serde_json::to_string(&request).unwrap();
2059        let parsed: serde_json::Value = serde_json::from_str(&serialized).unwrap();
2060
2061        assert_eq!(parsed["id"], "req-123");
2062        assert_eq!(parsed["op"], "order");
2063        assert!(parsed["args"].is_array());
2064        assert_eq!(parsed["args"].as_array().unwrap().len(), 1);
2065    }
2066
2067    #[rstest]
2068    fn test_ws_post_order_params_serializes_inst_id_code() {
2069        use super::WsPostOrderParamsBuilder;
2070        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
2071
2072        let params = WsPostOrderParamsBuilder::default()
2073            .inst_id_code(10459u64)
2074            .td_mode(OKXTradeMode::Cross)
2075            .side(OKXSide::Buy)
2076            .ord_type(OKXOrderType::Limit)
2077            .sz("0.01".to_string())
2078            .px("50000".to_string())
2079            .build()
2080            .unwrap();
2081
2082        let json = serde_json::to_string(&params).unwrap();
2083
2084        assert!(json.contains("\"instIdCode\":10459"));
2085        assert!(!json.contains("\"instId\""));
2086    }
2087
2088    #[rstest]
2089    fn test_ws_post_order_params_serializes_slippage_pct() {
2090        use super::WsPostOrderParamsBuilder;
2091        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
2092
2093        let params = WsPostOrderParamsBuilder::default()
2094            .inst_id_code(10459u64)
2095            .td_mode(OKXTradeMode::Cross)
2096            .side(OKXSide::Buy)
2097            .ord_type(OKXOrderType::Market)
2098            .sz("0.01".to_string())
2099            .slippage_pct("0.005".to_string())
2100            .build()
2101            .unwrap();
2102
2103        let json: serde_json::Value = serde_json::to_value(&params).unwrap();
2104        assert_eq!(json["slippagePct"], "0.005");
2105    }
2106
2107    #[rstest]
2108    fn test_ws_post_order_params_omits_slippage_pct_when_unset() {
2109        use super::WsPostOrderParamsBuilder;
2110        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
2111
2112        let params = WsPostOrderParamsBuilder::default()
2113            .inst_id_code(10459u64)
2114            .td_mode(OKXTradeMode::Cross)
2115            .side(OKXSide::Buy)
2116            .ord_type(OKXOrderType::Market)
2117            .sz("0.01".to_string())
2118            .build()
2119            .unwrap();
2120
2121        let json = serde_json::to_string(&params).unwrap();
2122        assert!(!json.contains("slippagePct"));
2123    }
2124
2125    #[rstest]
2126    fn test_ws_post_order_params_serializes_attached_tp_sl() {
2127        use super::{WsAttachAlgoOrdParamsBuilder, WsPostOrderParamsBuilder};
2128        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode, OKXTriggerType};
2129
2130        let params = WsPostOrderParamsBuilder::default()
2131            .inst_id_code(10459u64)
2132            .td_mode(OKXTradeMode::Cross)
2133            .side(OKXSide::Buy)
2134            .ord_type(OKXOrderType::Limit)
2135            .sz("0.01".to_string())
2136            .px("50000".to_string())
2137            .attach_algo_ords(vec![
2138                WsAttachAlgoOrdParamsBuilder::default()
2139                    .attach_algo_cl_ord_id("O-bracket-sl")
2140                    .sl_trigger_px("39000")
2141                    .sl_ord_px("-1")
2142                    .sl_trigger_px_type(OKXTriggerType::Last)
2143                    .build()
2144                    .unwrap(),
2145                WsAttachAlgoOrdParamsBuilder::default()
2146                    .attach_algo_cl_ord_id("O-bracket-tp")
2147                    .tp_trigger_px("41000")
2148                    .tp_ord_px("-1")
2149                    .tp_trigger_px_type(OKXTriggerType::Last)
2150                    .build()
2151                    .unwrap(),
2152            ])
2153            .build()
2154            .unwrap();
2155
2156        let json = serde_json::to_string(&params).unwrap();
2157
2158        assert!(json.contains("\"attachAlgoOrds\""));
2159        assert!(json.contains("\"attachAlgoClOrdId\":\"O-bracket-sl\""));
2160        assert!(json.contains("\"slTriggerPx\":\"39000\""));
2161        assert!(json.contains("\"slOrdPx\":\"-1\""));
2162        assert!(json.contains("\"attachAlgoClOrdId\":\"O-bracket-tp\""));
2163        assert!(json.contains("\"tpTriggerPx\":\"41000\""));
2164        assert!(json.contains("\"tpOrdPx\":\"-1\""));
2165    }
2166
2167    #[rstest]
2168    fn test_ws_cancel_order_params_serializes_inst_id_code() {
2169        use super::WsCancelOrderParamsBuilder;
2170
2171        let params = WsCancelOrderParamsBuilder::default()
2172            .inst_id_code(10461u64)
2173            .ord_id("12345678".to_string())
2174            .build()
2175            .unwrap();
2176
2177        let json = serde_json::to_string(&params).unwrap();
2178
2179        assert!(json.contains("\"instIdCode\":10461"));
2180        assert!(!json.contains("\"instId\""));
2181        assert!(json.contains("\"ordId\":\"12345678\""));
2182    }
2183
2184    #[rstest]
2185    fn test_ws_amend_order_params_serializes_inst_id_code() {
2186        use super::WsAmendOrderParamsBuilder;
2187
2188        let params = WsAmendOrderParamsBuilder::default()
2189            .inst_id_code(10459u64)
2190            .cl_ord_id("client123".to_string())
2191            .new_px("51000".to_string())
2192            .build()
2193            .unwrap();
2194
2195        let json = serde_json::to_string(&params).unwrap();
2196
2197        assert!(json.contains("\"instIdCode\":10459"));
2198        assert!(!json.contains("\"instId\""));
2199        assert!(json.contains("\"newPx\":\"51000\""));
2200    }
2201
2202    #[rstest]
2203    fn test_ws_post_algo_order_params_serializes_inst_id_code() {
2204        use super::WsPostAlgoOrderParamsBuilder;
2205        use crate::common::enums::{OKXAlgoOrderType, OKXSide, OKXTradeMode, OKXTriggerType};
2206
2207        let params = WsPostAlgoOrderParamsBuilder::default()
2208            .inst_id_code(10459u64)
2209            .td_mode(OKXTradeMode::Cross)
2210            .side(OKXSide::Buy)
2211            .ord_type(OKXAlgoOrderType::Trigger)
2212            .sz("0.01".to_string())
2213            .trigger_px("48000".to_string())
2214            .trigger_px_type(OKXTriggerType::Last)
2215            .build()
2216            .unwrap();
2217
2218        let json = serde_json::to_string(&params).unwrap();
2219
2220        assert!(json.contains("\"instIdCode\":10459"));
2221        assert!(!json.contains("\"instId\""));
2222        assert!(json.contains("\"triggerPx\":\"48000\""));
2223    }
2224
2225    #[rstest]
2226    fn test_ws_cancel_algo_order_params_serializes_inst_id_code() {
2227        let params = WsCancelAlgoOrderParams {
2228            inst_id_code: 10459,
2229            algo_id: Some("987654321".to_string()),
2230            algo_cl_ord_id: None,
2231        };
2232
2233        let json = serde_json::to_string(&params).unwrap();
2234
2235        assert!(json.contains("\"instIdCode\":10459"));
2236        assert!(!json.contains("\"instId\""));
2237        assert!(json.contains("\"algoId\":\"987654321\""));
2238    }
2239
2240    #[rstest]
2241    fn test_ws_post_order_params_serializes_px_usd() {
2242        use super::WsPostOrderParamsBuilder;
2243        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
2244
2245        let params = WsPostOrderParamsBuilder::default()
2246            .inst_id_code(10459u64)
2247            .td_mode(OKXTradeMode::Cross)
2248            .side(OKXSide::Buy)
2249            .ord_type(OKXOrderType::Limit)
2250            .sz("1".to_string())
2251            .px_usd("100.5".to_string())
2252            .build()
2253            .unwrap();
2254
2255        let json = serde_json::to_string(&params).unwrap();
2256        assert!(json.contains("\"pxUsd\":\"100.5\""));
2257        assert!(!json.contains("\"pxVol\""));
2258        assert!(!json.contains("\"px\":"));
2259    }
2260
2261    #[rstest]
2262    fn test_ws_post_order_params_serializes_px_vol() {
2263        use super::WsPostOrderParamsBuilder;
2264        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
2265
2266        let params = WsPostOrderParamsBuilder::default()
2267            .inst_id_code(10459u64)
2268            .td_mode(OKXTradeMode::Cross)
2269            .side(OKXSide::Buy)
2270            .ord_type(OKXOrderType::Limit)
2271            .sz("1".to_string())
2272            .px_vol("0.55".to_string())
2273            .build()
2274            .unwrap();
2275
2276        let json = serde_json::to_string(&params).unwrap();
2277        assert!(json.contains("\"pxVol\":\"0.55\""));
2278        assert!(!json.contains("\"pxUsd\""));
2279        assert!(!json.contains("\"px\":"));
2280    }
2281
2282    #[rstest]
2283    fn test_ws_amend_order_params_serializes_new_px_usd() {
2284        use super::WsAmendOrderParamsBuilder;
2285
2286        let params = WsAmendOrderParamsBuilder::default()
2287            .inst_id_code(10459u64)
2288            .cl_ord_id("client123".to_string())
2289            .new_px_usd("105.0".to_string())
2290            .build()
2291            .unwrap();
2292
2293        let json = serde_json::to_string(&params).unwrap();
2294        assert!(json.contains("\"newPxUsd\":\"105.0\""));
2295        assert!(!json.contains("\"newPx\":"));
2296        assert!(!json.contains("\"newPxVol\""));
2297    }
2298
2299    #[rstest]
2300    fn test_ws_amend_order_params_serializes_new_px_vol() {
2301        use super::WsAmendOrderParamsBuilder;
2302
2303        let params = WsAmendOrderParamsBuilder::default()
2304            .inst_id_code(10459u64)
2305            .cl_ord_id("client123".to_string())
2306            .new_px_vol("0.60".to_string())
2307            .build()
2308            .unwrap();
2309
2310        let json = serde_json::to_string(&params).unwrap();
2311        assert!(json.contains("\"newPxVol\":\"0.60\""));
2312        assert!(!json.contains("\"newPx\":"));
2313        assert!(!json.contains("\"newPxUsd\""));
2314    }
2315
2316    #[rstest]
2317    fn test_ws_event_contract_markets_channel_serialization() {
2318        let json = serde_json::to_string(&OKXWsChannel::EventContractMarkets).unwrap();
2319        let channel: OKXWsChannel = serde_json::from_str(&json).unwrap();
2320
2321        assert_eq!(json, "\"event-contract-markets\"");
2322        assert_eq!(channel, OKXWsChannel::EventContractMarkets);
2323    }
2324
2325    #[rstest]
2326    fn test_ws_post_order_params_serializes_event_contract_fields() {
2327        use super::WsPostOrderParamsBuilder;
2328        use crate::common::enums::{OKXOrderType, OKXSide, OKXTradeMode};
2329
2330        let params = WsPostOrderParamsBuilder::default()
2331            .inst_id_code(10459u64)
2332            .td_mode(OKXTradeMode::Cash)
2333            .side(OKXSide::Buy)
2334            .ord_type(OKXOrderType::Limit)
2335            .sz("10".to_string())
2336            .px("0.42".to_string())
2337            .speed_bump("1")
2338            .outcome("yes")
2339            .build()
2340            .unwrap();
2341
2342        let json: serde_json::Value = serde_json::to_value(&params).unwrap();
2343
2344        assert_eq!(json["speedBump"], "1");
2345        assert_eq!(json["outcome"], "yes");
2346    }
2347
2348    #[rstest]
2349    fn test_ws_amend_order_params_serializes_speed_bump() {
2350        use super::WsAmendOrderParamsBuilder;
2351
2352        let params = WsAmendOrderParamsBuilder::default()
2353            .inst_id_code(10459u64)
2354            .cl_ord_id("event-1".to_string())
2355            .new_px("0.43".to_string())
2356            .speed_bump("1")
2357            .build()
2358            .unwrap();
2359
2360        let json: serde_json::Value = serde_json::to_value(&params).unwrap();
2361
2362        assert_eq!(json["speedBump"], "1");
2363    }
2364
2365    #[rstest]
2366    fn test_ws_attach_algo_ord_params_serializes_trailing_fields() {
2367        use super::WsAttachAlgoOrdParamsBuilder;
2368
2369        let params = WsAttachAlgoOrdParamsBuilder::default()
2370            .attach_algo_cl_ord_id("trail-1")
2371            .callback_ratio("0.01")
2372            .active_px("64000")
2373            .new_callback_ratio("0.02")
2374            .new_callback_spread("25")
2375            .new_active_px("65000")
2376            .build()
2377            .unwrap();
2378
2379        let json: serde_json::Value = serde_json::to_value(&params).unwrap();
2380
2381        assert_eq!(json["callbackRatio"], "0.01");
2382        assert_eq!(json["activePx"], "64000");
2383        assert_eq!(json["newCallbackRatio"], "0.02");
2384        assert_eq!(json["newCallbackSpread"], "25");
2385        assert_eq!(json["newActivePx"], "65000");
2386        assert!(json.get("callbackSpread").is_none());
2387    }
2388
2389    #[rstest]
2390    fn test_subscription_arg_serializes_sprd_id_for_spread_channels() {
2391        let arg = OKXSubscriptionArg {
2392            channel: OKXWsChannel::SprdBooks5,
2393            inst_type: None,
2394            inst_family: None,
2395            inst_id: Some(Ustr::from("ETH-USD-260925_ETH-USD-261225")),
2396        };
2397        let json = serde_json::to_value(&arg).unwrap();
2398        assert_eq!(json["channel"], "sprd-books5");
2399        assert_eq!(json["sprdId"], "ETH-USD-260925_ETH-USD-261225");
2400        assert!(json.get("instId").is_none());
2401    }
2402
2403    #[rstest]
2404    fn test_subscription_arg_serializes_inst_id_for_standard_channels() {
2405        let arg = OKXSubscriptionArg {
2406            channel: OKXWsChannel::BboTbt,
2407            inst_type: None,
2408            inst_family: None,
2409            inst_id: Some(Ustr::from("BTC-USDT")),
2410        };
2411        let json = serde_json::to_value(&arg).unwrap();
2412        assert_eq!(json["instId"], "BTC-USDT");
2413        assert!(json.get("sprdId").is_none());
2414    }
2415
2416    #[rstest]
2417    fn test_websocket_arg_resolves_sprd_id_into_inst_id() {
2418        let arg: OKXWebSocketArg = serde_json::from_value(serde_json::json!({
2419            "channel": "sprd-bbo-tbt",
2420            "sprdId": "ETH-USD-260925_ETH-USD-261225",
2421        }))
2422        .unwrap();
2423        assert_eq!(arg.channel, OKXWsChannel::SprdBboTbt);
2424        assert_eq!(
2425            arg.inst_id,
2426            Some(Ustr::from("ETH-USD-260925_ETH-USD-261225"))
2427        );
2428    }
2429
2430    #[rstest]
2431    fn test_book_msg_parses_three_element_spread_levels() {
2432        // sprd-books5 levels are [price, size, count] (3 elements), unlike the
2433        // 4-element standard book levels.
2434        let msg: OKXBookMsg = serde_json::from_value(serde_json::json!({
2435            "asks": [["16.7", "100", "1"]],
2436            "bids": [["16.65", "100", "1"]],
2437            "ts": "1780044924909",
2438            "seqId": 1779935772619784_u64,
2439        }))
2440        .unwrap();
2441        assert_eq!(msg.asks[0].price, "16.7");
2442        assert_eq!(msg.asks[0].size, "100");
2443        assert_eq!(msg.bids[0].price, "16.65");
2444    }
2445
2446    #[rstest]
2447    fn test_trade_msg_parses_spread_public_trade() {
2448        // sprd-public-trades keys the instrument as `sprdId` and omits `count`.
2449        let msg: OKXTradeMsg = serde_json::from_value(serde_json::json!({
2450            "sprdId": "ETH-USD-260925_ETH-USD-261225",
2451            "tradeId": "3392538740127301632",
2452            "px": "16.9",
2453            "sz": "100",
2454            "side": "sell",
2455            "ts": "1780047866507",
2456        }))
2457        .unwrap();
2458        assert_eq!(msg.inst_id, Ustr::from("ETH-USD-260925_ETH-USD-261225"));
2459        assert_eq!(msg.px, "16.9");
2460        assert_eq!(msg.side, OKXSide::Sell);
2461        assert!(msg.count.is_empty());
2462    }
2463}