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