Skip to main content

nautilus_okx/websocket/
parse.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//! Functions translating raw OKX WebSocket frames into Nautilus data types.
17
18use std::{str::FromStr, sync::LazyLock};
19
20use ahash::{AHashMap, AHashSet};
21use anyhow::Context;
22use nautilus_core::{UUID4, nanos::UnixNanos};
23use nautilus_model::{
24    data::{
25        Bar, BarSpecification, BarType, BookOrder, Data, FundingRateUpdate, IndexPriceUpdate,
26        InstrumentStatus, MarkPriceUpdate, OptionGreekValues, OrderBookDelta, OrderBookDeltas,
27        OrderBookDepth10, QuoteTick, TradeTick, depth::DEPTH10_LEN, option_chain::OptionGreeks,
28    },
29    enums::{
30        AggregationSource, AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus,
31        OrderType, RecordFlag, TimeInForce, TrailingOffsetType, TriggerType,
32    },
33    events::{OrderAccepted, OrderCanceled, OrderExpired, OrderTriggered, OrderUpdated},
34    identifiers::{
35        AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, VenueOrderId,
36    },
37    instruments::{Instrument, InstrumentAny},
38    reports::{FillReport, OrderStatusReport},
39    types::{Money, Price, Quantity},
40};
41use parking_lot::Mutex;
42use rust_decimal::Decimal;
43use ustr::Ustr;
44
45use super::{
46    enums::OKXWsChannel,
47    messages::{
48        OKXAlgoOrderMsg, OKXBookMsg, OKXCandleMsg, OKXIndexPriceMsg, OKXMarkPriceMsg,
49        OKXOptionSummaryMsg, OKXOrderMsg, OKXRpiBookMsg, OKXTickerMsg, OKXTradeMsg, OrderBookEntry,
50    },
51};
52use crate::{
53    common::{
54        consts::{OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE},
55        enums::{
56            OKXAlgoOrderStatus, OKXAlgoOrderType, OKXBookAction, OKXCandleConfirm, OKXGreeksType,
57            OKXInstrumentStatus, OKXInstrumentType, OKXOrderCategory, OKXOrderStatus, OKXOrderType,
58            OKXSide, OKXTargetCurrency, OKXTriggerType,
59        },
60        models::OKXInstrument,
61        parse::{
62            determine_order_type_with_alt, is_market_price, okx_channel_to_bar_spec,
63            okx_status_to_market_action, parse_fee, parse_fee_currency, parse_funding_rate_msg,
64            parse_instrument_any, parse_instrument_id, parse_message_vec,
65            parse_millisecond_timestamp, parse_parent_client_order_id, parse_price, parse_quantity,
66            parse_spread_order_status_report as parse_common_spread_order_status_report,
67        },
68    },
69    http::models::OKXSpreadOrder,
70    websocket::messages::{ExecutionReport, NautilusWsMessage, OKXFundingRateMsg},
71};
72
73/// Extracts fee rates from a cached instrument.
74///
75/// Returns a tuple of (margin_init, margin_maint, maker_fee, taker_fee).
76/// All values are None if the instrument type doesn't support fees.
77pub(crate) fn extract_fees_from_cached_instrument(
78    instrument: &InstrumentAny,
79) -> (
80    Option<Decimal>,
81    Option<Decimal>,
82    Option<Decimal>,
83    Option<Decimal>,
84) {
85    match instrument {
86        InstrumentAny::CurrencyPair(pair) => (
87            Some(pair.margin_init),
88            Some(pair.margin_maint),
89            Some(pair.maker_fee),
90            Some(pair.taker_fee),
91        ),
92        InstrumentAny::CryptoPerpetual(perp) => (
93            Some(perp.margin_init),
94            Some(perp.margin_maint),
95            Some(perp.maker_fee),
96            Some(perp.taker_fee),
97        ),
98        InstrumentAny::CryptoFuture(future) => (
99            Some(future.margin_init),
100            Some(future.margin_maint),
101            Some(future.maker_fee),
102            Some(future.taker_fee),
103        ),
104        InstrumentAny::CryptoOption(option) => (
105            Some(option.margin_init),
106            Some(option.margin_maint),
107            Some(option.maker_fee),
108            Some(option.taker_fee),
109        ),
110        _ => (None, None, None, None),
111    }
112}
113
114/// Represents the result of parsing an OKX order message into a specific event.
115#[derive(Debug, Clone)]
116pub enum ParsedOrderEvent {
117    /// Order has been accepted by the venue.
118    Accepted(OrderAccepted),
119    /// Order has been canceled.
120    Canceled(OrderCanceled),
121    /// Order has expired (e.g., GTD order reached expiration time).
122    Expired(OrderExpired),
123    /// Stop/algo order has been triggered.
124    Triggered(OrderTriggered),
125    /// Order has been modified (price, quantity, or venue order ID changed).
126    Updated(OrderUpdated),
127    /// Order fill event.
128    Fill(FillReport),
129    /// Status update that doesn't map to a specific event (for reconciliation/external orders).
130    StatusOnly(Box<OrderStatusReport>),
131    /// Duplicate message detected (e.g. reconnect replay with unchanged fill).
132    /// The dispatcher should update caches but not emit any event.
133    Skipped,
134}
135
136/// Snapshot of order state for detecting updates.
137#[derive(Debug, Clone)]
138pub struct OrderStateSnapshot {
139    pub venue_order_id: VenueOrderId,
140    pub quantity: Quantity,
141    pub price: Option<Price>,
142}
143
144/// Parses an OKX order message into a specific order event.
145///
146/// This function determines the appropriate event type based on:
147/// - Current order status from OKX
148/// - Whether there's a new fill
149/// - Whether the order was updated (price/quantity change)
150/// - Whether it's an algo order that triggered
151///
152/// # Errors
153///
154/// Returns an error if parsing order identifiers or numeric fields fails.
155#[expect(clippy::too_many_arguments)]
156pub fn parse_order_event(
157    msg: &OKXOrderMsg,
158    client_order_id: ClientOrderId,
159    account_id: AccountId,
160    trader_id: TraderId,
161    strategy_id: StrategyId,
162    instrument: &InstrumentAny,
163    previous_fee: Option<Money>,
164    previous_filled_qty: Option<Quantity>,
165    previous_state: Option<&OrderStateSnapshot>,
166    ts_init: UnixNanos,
167) -> anyhow::Result<ParsedOrderEvent> {
168    let venue_order_id = VenueOrderId::new(msg.ord_id);
169    let instrument_id = instrument.id();
170
171    let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0")
172        || !msg.trade_id.is_empty()
173        || has_acc_fill_sz_increased(
174            &msg.acc_fill_sz,
175            previous_filled_qty,
176            instrument.size_precision(),
177        );
178
179    warn_unrecognized_order_state(msg);
180
181    // Check for order updates, but skip when other events take precedence:
182    // - Fill events: fill data must be processed, update detection secondary
183    // - Terminal states: handled by specific branches below
184    let skip_update_check = has_new_fill
185        || matches!(
186            msg.state,
187            OKXOrderStatus::Filled | OKXOrderStatus::Canceled | OKXOrderStatus::MmpCanceled
188        );
189
190    if !skip_update_check
191        && let Some(prev) = previous_state
192        && is_order_updated_excluding_venue_id_for_live(msg, prev, instrument)?
193    {
194        let ts_event = parse_millisecond_timestamp(msg.u_time);
195        let quantity = parse_quantity(&msg.sz, instrument.size_precision())?;
196        let price = if is_market_price(&msg.px) {
197            None
198        } else {
199            Some(parse_price(&msg.px, instrument.price_precision())?)
200        };
201
202        return Ok(ParsedOrderEvent::Updated(OrderUpdated::new(
203            trader_id,
204            strategy_id,
205            instrument_id,
206            client_order_id,
207            quantity,
208            UUID4::new(),
209            ts_event,
210            ts_init,
211            false, // reconciliation
212            Some(venue_order_id),
213            Some(account_id),
214            price,
215            None,  // trigger_price
216            None,  // protection_price
217            false, // is_quote_quantity
218        )));
219    }
220
221    match msg.state {
222        OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled | OKXOrderStatus::Unknown
223            if has_new_fill =>
224        {
225            match parse_fill_report(
226                msg,
227                instrument,
228                account_id,
229                previous_fee,
230                previous_filled_qty,
231                ts_init,
232            )? {
233                Some(mut report) => {
234                    report.client_order_id = Some(client_order_id);
235                    Ok(ParsedOrderEvent::Fill(report))
236                }
237                None => Ok(ParsedOrderEvent::Skipped),
238            }
239        }
240        OKXOrderStatus::Live => {
241            let ts_event = parse_millisecond_timestamp(msg.c_time);
242            Ok(ParsedOrderEvent::Accepted(OrderAccepted::new(
243                trader_id,
244                strategy_id,
245                instrument_id,
246                client_order_id,
247                venue_order_id,
248                account_id,
249                UUID4::new(),
250                ts_event,
251                ts_init,
252                false, // reconciliation
253            )))
254        }
255        OKXOrderStatus::Canceled | OKXOrderStatus::MmpCanceled => {
256            let ts_event = parse_millisecond_timestamp(msg.u_time);
257
258            if is_order_expired_by_reason(msg) {
259                Ok(ParsedOrderEvent::Expired(OrderExpired::new(
260                    trader_id,
261                    strategy_id,
262                    instrument_id,
263                    client_order_id,
264                    UUID4::new(),
265                    ts_event,
266                    ts_init,
267                    false,
268                    Some(venue_order_id),
269                    Some(account_id),
270                )))
271            } else {
272                Ok(ParsedOrderEvent::Canceled(OrderCanceled::new(
273                    trader_id,
274                    strategy_id,
275                    instrument_id,
276                    client_order_id,
277                    UUID4::new(),
278                    ts_event,
279                    ts_init,
280                    false,
281                    Some(venue_order_id),
282                    Some(account_id),
283                )))
284            }
285        }
286        _ => {
287            // PartiallyFilled without new fill or other states - use status report
288            parse_order_status_report(msg, instrument, account_id, ts_init)
289                .map(|r| ParsedOrderEvent::StatusOnly(Box::new(r)))
290        }
291    }
292}
293
294/// Parses an OKX spread order message into a specific order event.
295///
296/// # Errors
297///
298/// Returns an error if parsing order identifiers or numeric fields fails.
299#[expect(clippy::too_many_arguments)]
300pub fn parse_spread_order_event(
301    msg: &OKXSpreadOrder,
302    client_order_id: ClientOrderId,
303    account_id: AccountId,
304    trader_id: TraderId,
305    strategy_id: StrategyId,
306    instrument: &InstrumentAny,
307    previous_filled_qty: Option<Quantity>,
308    previous_state: Option<&OrderStateSnapshot>,
309    ts_init: UnixNanos,
310) -> anyhow::Result<ParsedOrderEvent> {
311    let venue_order_id = VenueOrderId::new(msg.ord_id.as_str());
312    let instrument_id = instrument.id();
313    let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0")
314        || !msg.trade_id.is_empty()
315        || has_acc_fill_sz_increased_value(
316            Some(msg.acc_fill_sz.as_str()),
317            previous_filled_qty,
318            instrument.size_precision(),
319        );
320    let skip_update_check = has_new_fill
321        || matches!(
322            msg.state,
323            OKXOrderStatus::Filled | OKXOrderStatus::Canceled | OKXOrderStatus::MmpCanceled
324        );
325
326    if !skip_update_check
327        && let Some(prev) = previous_state
328        && is_spread_order_updated_excluding_venue_id_for_live(msg, prev, instrument)?
329    {
330        let ts_event = msg.u_time.map_or(ts_init, parse_millisecond_timestamp);
331        let quantity = parse_quantity(&msg.sz, instrument.size_precision())?;
332        let price = if is_market_price(&msg.px) {
333            None
334        } else {
335            Some(parse_price(&msg.px, instrument.price_precision())?)
336        };
337
338        return Ok(ParsedOrderEvent::Updated(OrderUpdated::new(
339            trader_id,
340            strategy_id,
341            instrument_id,
342            client_order_id,
343            quantity,
344            UUID4::new(),
345            ts_event,
346            ts_init,
347            false,
348            Some(venue_order_id),
349            Some(account_id),
350            price,
351            None,
352            None,
353            false,
354        )));
355    }
356
357    match msg.state {
358        OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled if has_new_fill => {
359            match parse_spread_order_fill_report(
360                msg,
361                instrument,
362                account_id,
363                previous_filled_qty,
364                ts_init,
365            )? {
366                Some(report) => Ok(ParsedOrderEvent::Fill(report)),
367                None => Ok(ParsedOrderEvent::Skipped),
368            }
369        }
370        OKXOrderStatus::Live => {
371            let ts_event = msg.c_time.map_or(ts_init, parse_millisecond_timestamp);
372            Ok(ParsedOrderEvent::Accepted(OrderAccepted::new(
373                trader_id,
374                strategy_id,
375                instrument_id,
376                client_order_id,
377                venue_order_id,
378                account_id,
379                UUID4::new(),
380                ts_event,
381                ts_init,
382                false,
383            )))
384        }
385        OKXOrderStatus::Canceled | OKXOrderStatus::MmpCanceled => {
386            let ts_event = msg
387                .u_time
388                .or(msg.c_time)
389                .map_or(ts_init, parse_millisecond_timestamp);
390            Ok(ParsedOrderEvent::Canceled(OrderCanceled::new(
391                trader_id,
392                strategy_id,
393                instrument_id,
394                client_order_id,
395                UUID4::new(),
396                ts_event,
397                ts_init,
398                false,
399                Some(venue_order_id),
400                Some(account_id),
401            )))
402        }
403        _ => parse_common_spread_order_status_report(
404            msg,
405            account_id,
406            instrument.id(),
407            instrument.price_precision(),
408            instrument.size_precision(),
409            ts_init,
410        )
411        .map(|report| ParsedOrderEvent::StatusOnly(Box::new(report))),
412    }
413}
414
415/// Case-insensitive substring check.
416#[inline]
417/// Builds a deterministic synthesized `TradeId` for fills where OKX omits
418/// the venue `trade_id`. Hashes the immutable fill fields with FNV-1a so
419/// the result fits inside the 36-character `TradeId` cap and stays stable
420/// across reconnect replays of the same physical fill.
421fn synthesize_trade_id(msg: &OKXOrderMsg) -> String {
422    const FNV_OFFSET: u64 = 0xcbf29ce484222325;
423    const FNV_PRIME: u64 = 0x100000001b3;
424
425    let mut hasher: u64 = FNV_OFFSET;
426    let mut update = |bytes: &[u8]| {
427        for byte in bytes {
428            hasher ^= u64::from(*byte);
429            hasher = hasher.wrapping_mul(FNV_PRIME);
430        }
431        // Field separator so that "ab" + "c" doesn't hash to the same as "a" + "bc".
432        hasher ^= 0xff;
433        hasher = hasher.wrapping_mul(FNV_PRIME);
434    };
435
436    update(msg.ord_id.as_bytes());
437    update(msg.fill_time.to_string().as_bytes());
438    update(msg.fill_sz.as_bytes());
439    update(msg.fill_px.as_bytes());
440    update(msg.acc_fill_sz.as_deref().unwrap_or("").as_bytes());
441
442    format!("synth-{hasher:016x}")
443}
444
445fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
446    haystack
447        .as_bytes()
448        .windows(needle.len())
449        .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
450}
451
452/// Determines if a Canceled order is actually an Expired order based on cancel reason.
453///
454/// OKX's numeric `cancelSource` enumeration is not fully documented, so
455/// unmapped codes log once via [`log_unknown_cancel_source`] to surface
456/// candidates for the mapping.
457fn is_order_expired_by_reason(msg: &OKXOrderMsg) -> bool {
458    if let Some(ref reason) = msg.cancel_source_reason
459        && (contains_ignore_ascii_case(reason, "expir")
460            || contains_ignore_ascii_case(reason, "gtd")
461            || contains_ignore_ascii_case(reason, "timeout"))
462    {
463        return true;
464    }
465
466    if let Some(ref source) = msg.cancel_source
467        && (source == "5" || source == "time_expired" || source == "gtd_expired")
468    {
469        return true;
470    }
471
472    log_unknown_cancel_source(msg);
473    false
474}
475
476const MAX_TRACKED_CANCEL_SOURCES: usize = 64;
477
478/// Logs each unseen `(cancel_source, cancel_source_reason)` pair once,
479/// capped at [`MAX_TRACKED_CANCEL_SOURCES`].
480fn log_unknown_cancel_source(msg: &OKXOrderMsg) {
481    static SEEN: LazyLock<Mutex<AHashSet<String>>> = LazyLock::new(|| Mutex::new(AHashSet::new()));
482    log_unknown_cancel_source_inner(msg, &SEEN, MAX_TRACKED_CANCEL_SOURCES);
483}
484
485/// Test-injectable variant. Returns `true` when a new entry was recorded.
486fn log_unknown_cancel_source_inner(
487    msg: &OKXOrderMsg,
488    seen: &Mutex<AHashSet<String>>,
489    max_tracked: usize,
490) -> bool {
491    let source = msg.cancel_source.as_deref().unwrap_or("");
492    let reason = msg.cancel_source_reason.as_deref().unwrap_or("");
493
494    if source.is_empty() && reason.is_empty() {
495        return false;
496    }
497
498    if matches!(source, "5" | "31" | "time_expired" | "gtd_expired") {
499        return false;
500    }
501
502    let key = format!("{source}|{reason}");
503    let mut seen = seen.lock();
504
505    if seen.len() >= max_tracked {
506        return false;
507    }
508
509    if seen.insert(key) {
510        log::debug!("Observed unmapped OKX cancelSource: source='{source}', reason='{reason}'");
511        true
512    } else {
513        false
514    }
515}
516
517/// Checks if order parameters have been updated compared to previous state.
518///
519/// For Live status, venue_id changes are ignored because algo triggers create new IDs
520/// but should emit OrderAccepted, not OrderUpdated. Price/quantity changes still count.
521fn is_order_updated_excluding_venue_id_for_live(
522    msg: &OKXOrderMsg,
523    previous: &OrderStateSnapshot,
524    instrument: &InstrumentAny,
525) -> anyhow::Result<bool> {
526    // For non-Live statuses, venue_id change indicates amendment
527    if msg.state != OKXOrderStatus::Live {
528        let current_venue_id = VenueOrderId::new(msg.ord_id);
529        if previous.venue_order_id != current_venue_id {
530            return Ok(true);
531        }
532    }
533
534    let current_qty = parse_quantity(&msg.sz, instrument.size_precision())?;
535    if previous.quantity != current_qty {
536        return Ok(true);
537    }
538
539    // Price change only applies to limit orders
540    if !is_market_price(&msg.px) {
541        let current_price = parse_price(&msg.px, instrument.price_precision())?;
542
543        if let Some(prev_price) = previous.price
544            && prev_price != current_price
545        {
546            return Ok(true);
547        }
548    }
549
550    Ok(false)
551}
552
553fn is_spread_order_updated_excluding_venue_id_for_live(
554    msg: &OKXSpreadOrder,
555    previous: &OrderStateSnapshot,
556    instrument: &InstrumentAny,
557) -> anyhow::Result<bool> {
558    if msg.state != OKXOrderStatus::Live {
559        let current_venue_id = VenueOrderId::new(msg.ord_id.as_str());
560        if previous.venue_order_id != current_venue_id {
561            return Ok(true);
562        }
563    }
564
565    let current_qty = parse_quantity(&msg.sz, instrument.size_precision())?;
566    if previous.quantity != current_qty {
567        return Ok(true);
568    }
569
570    if !is_market_price(&msg.px) {
571        let current_price = parse_price(&msg.px, instrument.price_precision())?;
572
573        if let Some(prev_price) = previous.price
574            && prev_price != current_price
575        {
576            return Ok(true);
577        }
578    }
579
580    Ok(false)
581}
582
583/// Checks if order parameters have been updated (used by tests).
584#[cfg(test)]
585fn is_order_updated(
586    msg: &OKXOrderMsg,
587    previous: &OrderStateSnapshot,
588    instrument: &InstrumentAny,
589) -> anyhow::Result<bool> {
590    let current_venue_id = VenueOrderId::new(msg.ord_id);
591
592    // Venue order ID change indicates amendment
593    if previous.venue_order_id != current_venue_id {
594        return Ok(true);
595    }
596
597    let current_qty = parse_quantity(&msg.sz, instrument.size_precision())?;
598    if previous.quantity != current_qty {
599        return Ok(true);
600    }
601
602    // Price change only applies to limit orders
603    if !is_market_price(&msg.px) {
604        let current_price = parse_price(&msg.px, instrument.price_precision())?;
605
606        if let Some(prev_price) = previous.price
607            && prev_price != current_price
608        {
609            return Ok(true);
610        }
611    }
612
613    Ok(false)
614}
615
616/// Parses vector of OKX book messages into Nautilus order book deltas.
617///
618/// # Errors
619///
620/// Returns an error if any underlying book message cannot be parsed.
621pub fn parse_book_msg_vec(
622    data: Vec<OKXBookMsg>,
623    instrument_id: &InstrumentId,
624    price_precision: u8,
625    size_precision: u8,
626    action: OKXBookAction,
627    ts_init: UnixNanos,
628) -> anyhow::Result<Vec<Data>> {
629    let mut deltas = Vec::with_capacity(data.len());
630
631    for msg in data {
632        deltas.push(Data::Deltas(Box::new(parse_book_msg(
633            &msg,
634            *instrument_id,
635            price_precision,
636            size_precision,
637            &action,
638            ts_init,
639        )?)));
640    }
641
642    Ok(deltas)
643}
644
645/// Parses RPI book messages into Nautilus order book deltas.
646///
647/// # Errors
648///
649/// Returns an error if any RPI book message cannot be represented at the instrument precision.
650pub fn parse_rpi_book_msg_vec(
651    data: Vec<OKXRpiBookMsg>,
652    instrument_id: &InstrumentId,
653    price_precision: u8,
654    size_precision: u8,
655    action: OKXBookAction,
656    ts_init: UnixNanos,
657) -> anyhow::Result<Vec<Data>> {
658    let mut deltas = Vec::with_capacity(data.len());
659
660    for msg in data {
661        deltas.push(Data::Deltas(Box::new(parse_rpi_book_msg(
662            &msg,
663            *instrument_id,
664            price_precision,
665            size_precision,
666            &action,
667            ts_init,
668        )?)));
669    }
670
671    Ok(deltas)
672}
673
674/// Parses vector of OKX ticker messages into Nautilus quote ticks.
675///
676/// # Errors
677///
678/// Returns an error if any ticker message fails to parse.
679pub fn parse_ticker_msg_vec(
680    data: serde_json::Value,
681    instrument_id: &InstrumentId,
682    price_precision: u8,
683    size_precision: u8,
684    ts_init: UnixNanos,
685) -> anyhow::Result<Vec<Data>> {
686    parse_message_vec(
687        data,
688        |msg| {
689            parse_ticker_msg(
690                msg,
691                *instrument_id,
692                price_precision,
693                size_precision,
694                ts_init,
695            )
696        },
697        Data::Quote,
698    )
699}
700
701/// Parses vector of OKX book messages into Nautilus quote ticks.
702///
703/// # Errors
704///
705/// Returns an error if any quote message fails to parse.
706pub fn parse_quote_msg_vec(
707    data: serde_json::Value,
708    instrument_id: &InstrumentId,
709    price_precision: u8,
710    size_precision: u8,
711    ts_init: UnixNanos,
712) -> anyhow::Result<Vec<Data>> {
713    parse_message_vec(
714        data,
715        |msg| {
716            parse_quote_msg(
717                msg,
718                *instrument_id,
719                price_precision,
720                size_precision,
721                ts_init,
722            )
723        },
724        Data::Quote,
725    )
726}
727
728/// Parses vector of OKX trade messages into Nautilus trade ticks.
729///
730/// # Errors
731///
732/// Returns an error if any trade message fails to parse.
733pub fn parse_trade_msg_vec(
734    data: serde_json::Value,
735    instrument_id: &InstrumentId,
736    price_precision: u8,
737    size_precision: u8,
738    ts_init: UnixNanos,
739) -> anyhow::Result<Vec<Data>> {
740    parse_message_vec(
741        data,
742        |msg| {
743            parse_trade_msg(
744                msg,
745                *instrument_id,
746                price_precision,
747                size_precision,
748                ts_init,
749            )
750        },
751        Data::Trade,
752    )
753}
754
755/// Parses vector of OKX mark price messages into Nautilus mark price updates.
756///
757/// # Errors
758///
759/// Returns an error if any mark price message fails to parse.
760pub fn parse_mark_price_msg_vec(
761    data: serde_json::Value,
762    instrument_id: &InstrumentId,
763    price_precision: u8,
764    ts_init: UnixNanos,
765) -> anyhow::Result<Vec<Data>> {
766    parse_message_vec(
767        data,
768        |msg| parse_mark_price_msg(msg, *instrument_id, price_precision, ts_init),
769        Data::MarkPrice,
770    )
771}
772
773/// Parses vector of OKX index price messages into Nautilus index price updates.
774///
775/// # Errors
776///
777/// Returns an error if any index price message fails to parse.
778pub fn parse_index_price_msg_vec(
779    data: serde_json::Value,
780    instrument_id: &InstrumentId,
781    price_precision: u8,
782    ts_init: UnixNanos,
783) -> anyhow::Result<Vec<Data>> {
784    parse_message_vec(
785        data,
786        |msg| parse_index_price_msg(msg, *instrument_id, price_precision, ts_init),
787        Data::IndexPrice,
788    )
789}
790
791/// Parses vector of OKX funding rate messages into Nautilus funding rate updates.
792/// Includes caching to filter out duplicate funding rates.
793///
794/// # Errors
795///
796/// Returns an error if any funding rate message fails to parse.
797pub fn parse_funding_rate_msg_vec(
798    data: serde_json::Value,
799    instrument_id: &InstrumentId,
800    ts_init: UnixNanos,
801    funding_cache: &mut AHashMap<Ustr, (Ustr, u64)>,
802) -> anyhow::Result<Vec<FundingRateUpdate>> {
803    let msgs: Vec<OKXFundingRateMsg> = serde_json::from_value(data)?;
804
805    let mut result = Vec::with_capacity(msgs.len());
806
807    for msg in &msgs {
808        let cache_key = (msg.funding_rate, msg.funding_time);
809
810        if let Some(cached) = funding_cache.get(&msg.inst_id)
811            && *cached == cache_key
812        {
813            continue; // Skip duplicate
814        }
815
816        // New or changed funding rate, update cache and parse
817        funding_cache.insert(msg.inst_id, cache_key);
818        let funding_rate = parse_funding_rate_msg(msg, *instrument_id, ts_init)?;
819        result.push(funding_rate);
820    }
821
822    Ok(result)
823}
824
825/// Parses vector of OKX candle messages into Nautilus bars.
826///
827/// # Errors
828///
829/// Returns an error if candle messages cannot be deserialized or parsed.
830pub fn parse_candle_msg_vec(
831    data: serde_json::Value,
832    instrument_id: &InstrumentId,
833    price_precision: u8,
834    size_precision: u8,
835    spec: BarSpecification,
836    ts_init: UnixNanos,
837) -> anyhow::Result<Vec<Data>> {
838    let msgs: Vec<OKXCandleMsg> = serde_json::from_value(data)?;
839    let bar_type = BarType::new(*instrument_id, spec, AggregationSource::External);
840    let mut bars = Vec::with_capacity(msgs.len());
841
842    for msg in msgs {
843        // Only process completed candles to avoid duplicate/partial bars
844        if msg.confirm == OKXCandleConfirm::Closed {
845            let bar = parse_candle_msg(&msg, bar_type, price_precision, size_precision, ts_init)?;
846            bars.push(Data::Bar(bar));
847        }
848    }
849
850    Ok(bars)
851}
852
853/// Parses vector of OKX book messages into Nautilus depth10 updates.
854///
855/// # Errors
856///
857/// Returns an error if any book10 message fails to parse.
858pub fn parse_book10_msg_vec(
859    data: Vec<OKXBookMsg>,
860    instrument_id: &InstrumentId,
861    price_precision: u8,
862    size_precision: u8,
863    ts_init: UnixNanos,
864) -> anyhow::Result<Vec<Data>> {
865    let mut depth10_updates = Vec::with_capacity(data.len());
866
867    for msg in data {
868        let depth10 = parse_book10_msg(
869            &msg,
870            *instrument_id,
871            price_precision,
872            size_precision,
873            ts_init,
874        )?;
875        depth10_updates.push(Data::Depth10(Box::new(depth10)));
876    }
877
878    Ok(depth10_updates)
879}
880
881/// Parses an OKX book message into Nautilus order book deltas.
882///
883/// # Errors
884///
885/// Returns an error if bid or ask levels contain values that cannot be parsed.
886pub fn parse_book_msg(
887    msg: &OKXBookMsg,
888    instrument_id: InstrumentId,
889    price_precision: u8,
890    size_precision: u8,
891    action: &OKXBookAction,
892    ts_init: UnixNanos,
893) -> anyhow::Result<OrderBookDeltas> {
894    let flags = if action == &OKXBookAction::Snapshot {
895        RecordFlag::F_SNAPSHOT as u8
896    } else {
897        0
898    };
899    let ts_event = parse_millisecond_timestamp(msg.ts);
900
901    let mut deltas = Vec::with_capacity(msg.asks.len() + msg.bids.len());
902
903    for bid in &msg.bids {
904        let book_action = match action {
905            OKXBookAction::Snapshot => BookAction::Add,
906            _ => match bid.size.as_str() {
907                "0" => BookAction::Delete,
908                _ => BookAction::Update,
909            },
910        };
911        let price = parse_price(&bid.price, price_precision)?;
912        let size = parse_quantity(&bid.size, size_precision)?;
913        let order_id = 0; // TBD
914        let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
915        let delta = OrderBookDelta::new(
916            instrument_id,
917            book_action,
918            order,
919            flags,
920            msg.seq_id,
921            ts_event,
922            ts_init,
923        );
924        deltas.push(delta);
925    }
926
927    for ask in &msg.asks {
928        let book_action = match action {
929            OKXBookAction::Snapshot => BookAction::Add,
930            _ => match ask.size.as_str() {
931                "0" => BookAction::Delete,
932                _ => BookAction::Update,
933            },
934        };
935        let price = parse_price(&ask.price, price_precision)?;
936        let size = parse_quantity(&ask.size, size_precision)?;
937        let order_id = 0; // TBD
938        let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
939        let delta = OrderBookDelta::new(
940            instrument_id,
941            book_action,
942            order,
943            flags,
944            msg.seq_id,
945            ts_event,
946            ts_init,
947        );
948        deltas.push(delta);
949    }
950
951    OrderBookDeltas::new_checked(instrument_id, deltas)
952}
953
954/// Parses an RPI book message into Nautilus order book deltas.
955///
956/// # Errors
957///
958/// Returns an error if a price or total quantity cannot be represented at the instrument precision.
959pub fn parse_rpi_book_msg(
960    msg: &OKXRpiBookMsg,
961    instrument_id: InstrumentId,
962    price_precision: u8,
963    size_precision: u8,
964    action: &OKXBookAction,
965    ts_init: UnixNanos,
966) -> anyhow::Result<OrderBookDeltas> {
967    let flags = if action == &OKXBookAction::Snapshot {
968        RecordFlag::F_SNAPSHOT as u8
969    } else {
970        0
971    };
972    let ts_event = parse_millisecond_timestamp(msg.ts);
973    let mut deltas = Vec::with_capacity(msg.asks.len() + msg.bids.len());
974
975    for bid in &msg.bids {
976        let book_action = if action == &OKXBookAction::Snapshot {
977            BookAction::Add
978        } else if bid.1.is_zero() {
979            BookAction::Delete
980        } else {
981            BookAction::Update
982        };
983        let price = Price::from_decimal_dp(bid.0, price_precision)?;
984        let size = Quantity::from_decimal_dp(bid.1, size_precision)?;
985        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
986        deltas.push(OrderBookDelta::new(
987            instrument_id,
988            book_action,
989            order,
990            flags,
991            msg.seq_id,
992            ts_event,
993            ts_init,
994        ));
995    }
996
997    for ask in &msg.asks {
998        let book_action = if action == &OKXBookAction::Snapshot {
999            BookAction::Add
1000        } else if ask.1.is_zero() {
1001            BookAction::Delete
1002        } else {
1003            BookAction::Update
1004        };
1005        let price = Price::from_decimal_dp(ask.0, price_precision)?;
1006        let size = Quantity::from_decimal_dp(ask.1, size_precision)?;
1007        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
1008        deltas.push(OrderBookDelta::new(
1009            instrument_id,
1010            book_action,
1011            order,
1012            flags,
1013            msg.seq_id,
1014            ts_event,
1015            ts_init,
1016        ));
1017    }
1018
1019    OrderBookDeltas::new_checked(instrument_id, deltas)
1020}
1021
1022/// Parses an OKX book message into a Nautilus quote tick.
1023///
1024/// # Errors
1025///
1026/// Returns an error if any quote levels contain values that cannot be parsed.
1027pub fn parse_quote_msg(
1028    msg: &OKXBookMsg,
1029    instrument_id: InstrumentId,
1030    price_precision: u8,
1031    size_precision: u8,
1032    ts_init: UnixNanos,
1033) -> anyhow::Result<QuoteTick> {
1034    let best_bid: &OrderBookEntry = msg
1035        .bids
1036        .first()
1037        .ok_or_else(|| anyhow::anyhow!("Empty bids array for {instrument_id}"))?;
1038    let best_ask: &OrderBookEntry = msg
1039        .asks
1040        .first()
1041        .ok_or_else(|| anyhow::anyhow!("Empty asks array for {instrument_id}"))?;
1042
1043    let bid_price = parse_price(&best_bid.price, price_precision)?;
1044    let ask_price = parse_price(&best_ask.price, price_precision)?;
1045    let bid_size = parse_quantity(&best_bid.size, size_precision)?;
1046    let ask_size = parse_quantity(&best_ask.size, size_precision)?;
1047    let ts_event = parse_millisecond_timestamp(msg.ts);
1048
1049    QuoteTick::new_checked(
1050        instrument_id,
1051        bid_price,
1052        ask_price,
1053        bid_size,
1054        ask_size,
1055        ts_event,
1056        ts_init,
1057    )
1058}
1059
1060/// Parses an OKX book message into a Nautilus [`OrderBookDepth10`].
1061///
1062/// Converts order book data into a fixed-depth snapshot with top 10 levels for both sides.
1063///
1064/// # Errors
1065///
1066/// Returns an error if price or size fields cannot be parsed for any level.
1067pub fn parse_book10_msg(
1068    msg: &OKXBookMsg,
1069    instrument_id: InstrumentId,
1070    price_precision: u8,
1071    size_precision: u8,
1072    ts_init: UnixNanos,
1073) -> anyhow::Result<OrderBookDepth10> {
1074    let zero_price = Price::zero(price_precision);
1075    let zero_qty = Quantity::zero(size_precision);
1076    let empty_bid = BookOrder::new(OrderSide::Buy, zero_price, zero_qty, 0);
1077    let empty_ask = BookOrder::new(OrderSide::Sell, zero_price, zero_qty, 0);
1078
1079    let mut bids: [BookOrder; DEPTH10_LEN] = [empty_bid; DEPTH10_LEN];
1080    let mut asks: [BookOrder; DEPTH10_LEN] = [empty_ask; DEPTH10_LEN];
1081    let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
1082    let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
1083
1084    for (i, level) in msg.bids.iter().take(DEPTH10_LEN).enumerate() {
1085        let price = parse_price(&level.price, price_precision)?;
1086        let size = parse_quantity(&level.size, size_precision)?;
1087        let orders_count = level.orders_count.parse::<u32>().unwrap_or(1);
1088
1089        bids[i] = BookOrder::new(OrderSide::Buy, price, size, 0);
1090        bid_counts[i] = orders_count;
1091    }
1092
1093    for (i, level) in msg.asks.iter().take(DEPTH10_LEN).enumerate() {
1094        let price = parse_price(&level.price, price_precision)?;
1095        let size = parse_quantity(&level.size, size_precision)?;
1096        let orders_count = level.orders_count.parse::<u32>().unwrap_or(1);
1097
1098        asks[i] = BookOrder::new(OrderSide::Sell, price, size, 0);
1099        ask_counts[i] = orders_count;
1100    }
1101
1102    let ts_event = parse_millisecond_timestamp(msg.ts);
1103
1104    Ok(OrderBookDepth10::new(
1105        instrument_id,
1106        bids,
1107        asks,
1108        bid_counts,
1109        ask_counts,
1110        RecordFlag::F_SNAPSHOT as u8,
1111        msg.seq_id, // Use sequence ID for OKX L2 books
1112        ts_event,
1113        ts_init,
1114    ))
1115}
1116
1117/// Parses an OKX ticker message into a Nautilus quote tick.
1118///
1119/// # Errors
1120///
1121/// Returns an error if bid or ask values cannot be parsed from the message.
1122pub fn parse_ticker_msg(
1123    msg: &OKXTickerMsg,
1124    instrument_id: InstrumentId,
1125    price_precision: u8,
1126    size_precision: u8,
1127    ts_init: UnixNanos,
1128) -> anyhow::Result<QuoteTick> {
1129    let bid_price = parse_price(&msg.bid_px, price_precision)?;
1130    let ask_price = parse_price(&msg.ask_px, price_precision)?;
1131    let bid_size = parse_quantity(&msg.bid_sz, size_precision)?;
1132    let ask_size = parse_quantity(&msg.ask_sz, size_precision)?;
1133    let ts_event = parse_millisecond_timestamp(msg.ts);
1134
1135    QuoteTick::new_checked(
1136        instrument_id,
1137        bid_price,
1138        ask_price,
1139        bid_size,
1140        ask_size,
1141        ts_event,
1142        ts_init,
1143    )
1144}
1145
1146/// Parses an OKX trade message into a Nautilus trade tick.
1147///
1148/// # Errors
1149///
1150/// Returns an error if trade prices or sizes cannot be parsed.
1151pub fn parse_trade_msg(
1152    msg: &OKXTradeMsg,
1153    instrument_id: InstrumentId,
1154    price_precision: u8,
1155    size_precision: u8,
1156    ts_init: UnixNanos,
1157) -> anyhow::Result<TradeTick> {
1158    let price = parse_price(&msg.px, price_precision)?;
1159    let size = parse_quantity(&msg.sz, size_precision)?;
1160    let aggressor_side: AggressorSide = msg.side.into();
1161    let trade_id = TradeId::new(&msg.trade_id);
1162    let ts_event = parse_millisecond_timestamp(msg.ts);
1163
1164    TradeTick::new_checked(
1165        instrument_id,
1166        price,
1167        size,
1168        aggressor_side,
1169        trade_id,
1170        ts_event,
1171        ts_init,
1172    )
1173}
1174
1175/// Parses an OKX mark price message into a Nautilus mark price update.
1176///
1177/// # Errors
1178///
1179/// Returns an error if the mark price fails to parse.
1180pub fn parse_mark_price_msg(
1181    msg: &OKXMarkPriceMsg,
1182    instrument_id: InstrumentId,
1183    price_precision: u8,
1184    ts_init: UnixNanos,
1185) -> anyhow::Result<MarkPriceUpdate> {
1186    let price = parse_price(&msg.mark_px, price_precision)?;
1187    let ts_event = parse_millisecond_timestamp(msg.ts);
1188
1189    Ok(MarkPriceUpdate::new(
1190        instrument_id,
1191        price,
1192        ts_event,
1193        ts_init,
1194    ))
1195}
1196
1197/// Parses an OKX index price message into a Nautilus index price update.
1198///
1199/// # Errors
1200///
1201/// Returns an error if the index price fails to parse.
1202pub fn parse_index_price_msg(
1203    msg: &OKXIndexPriceMsg,
1204    instrument_id: InstrumentId,
1205    price_precision: u8,
1206    ts_init: UnixNanos,
1207) -> anyhow::Result<IndexPriceUpdate> {
1208    let price = parse_price(&msg.idx_px, price_precision)?;
1209    let ts_event = parse_millisecond_timestamp(msg.ts);
1210
1211    Ok(IndexPriceUpdate::new(
1212        instrument_id,
1213        price,
1214        ts_event,
1215        ts_init,
1216    ))
1217}
1218
1219/// Parses an OKX candle message into a Nautilus bar.
1220///
1221/// # Errors
1222///
1223/// Returns an error if candle price or volume fields cannot be parsed.
1224pub fn parse_candle_msg(
1225    msg: &OKXCandleMsg,
1226    bar_type: BarType,
1227    price_precision: u8,
1228    size_precision: u8,
1229    ts_init: UnixNanos,
1230) -> anyhow::Result<Bar> {
1231    let open = parse_price(&msg.o, price_precision)?;
1232    let high = parse_price(&msg.h, price_precision)?;
1233    let low = parse_price(&msg.l, price_precision)?;
1234    let close = parse_price(&msg.c, price_precision)?;
1235    let volume = parse_quantity(&msg.vol, size_precision)?;
1236    let ts_event = parse_millisecond_timestamp(msg.ts);
1237
1238    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
1239}
1240
1241/// Parses vector of OKX order messages into Nautilus execution reports.
1242///
1243/// # Errors
1244///
1245/// Returns an error if any contained order messages cannot be parsed.
1246pub fn parse_order_msg_vec(
1247    data: &[OKXOrderMsg],
1248    account_id: AccountId,
1249    instruments: &AHashMap<Ustr, InstrumentAny>,
1250    fee_cache: &mut AHashMap<Ustr, Money>,
1251    filled_qty_cache: &mut AHashMap<Ustr, Quantity>,
1252    ts_init: UnixNanos,
1253) -> anyhow::Result<Vec<ExecutionReport>> {
1254    let mut order_reports = Vec::with_capacity(data.len());
1255
1256    for msg in data {
1257        match parse_order_msg(
1258            msg,
1259            account_id,
1260            instruments,
1261            fee_cache,
1262            filled_qty_cache,
1263            ts_init,
1264        ) {
1265            Ok(report) => {
1266                order_reports.push(report);
1267
1268                if let Some(instrument) = instruments.get(&msg.inst_id) {
1269                    update_fee_fill_caches(msg, instrument, fee_cache, filled_qty_cache);
1270                }
1271            }
1272            Err(e) => log::error!("Failed to parse execution report from message: {e}"),
1273        }
1274    }
1275
1276    Ok(order_reports)
1277}
1278
1279/// Updates fee and fill caches from a raw OKX order message.
1280///
1281/// Call after parsing each message so subsequent messages in the same batch
1282/// see the correct cumulative fee and filled quantity.
1283pub fn update_fee_fill_caches(
1284    msg: &OKXOrderMsg,
1285    instrument: &InstrumentAny,
1286    fee_cache: &mut AHashMap<Ustr, Money>,
1287    filled_qty_cache: &mut AHashMap<Ustr, Quantity>,
1288) {
1289    if let Some(ref fee_str) = msg.fee
1290        && !fee_str.is_empty()
1291    {
1292        let fee_dec = Decimal::from_str(fee_str).unwrap_or_default();
1293        let fee_ccy = parse_fee_currency(msg.fee_ccy.as_str(), fee_dec, || {
1294            format!("update_fee_fill_caches ord_id={}", msg.ord_id)
1295        });
1296
1297        if let Ok(total_fee) = crate::common::parse::parse_fee(Some(fee_str.as_str()), fee_ccy) {
1298            fee_cache.insert(msg.ord_id, total_fee);
1299        }
1300    }
1301
1302    if let Some(ref acc_fill_sz) = msg.acc_fill_sz
1303        && !acc_fill_sz.is_empty()
1304        && acc_fill_sz != "0"
1305        && let Ok(qty) = parse_quantity(acc_fill_sz, instrument.size_precision())
1306    {
1307        filled_qty_cache.insert(msg.ord_id, qty);
1308    }
1309}
1310
1311/// Checks if acc_fill_sz has increased compared to the previous filled quantity.
1312fn has_acc_fill_sz_increased(
1313    acc_fill_sz: &Option<String>,
1314    previous_filled_qty: Option<Quantity>,
1315    size_precision: u8,
1316) -> bool {
1317    has_acc_fill_sz_increased_value(acc_fill_sz.as_deref(), previous_filled_qty, size_precision)
1318}
1319
1320fn has_acc_fill_sz_increased_value(
1321    acc_fill_sz: Option<&str>,
1322    previous_filled_qty: Option<Quantity>,
1323    size_precision: u8,
1324) -> bool {
1325    if let Some(acc_str) = acc_fill_sz {
1326        if acc_str.is_empty() || acc_str == "0" {
1327            return false;
1328        }
1329
1330        if let Ok(current_filled) = parse_quantity(acc_str, size_precision) {
1331            if let Some(prev_qty) = previous_filled_qty {
1332                return current_filled > prev_qty;
1333            }
1334            return !current_filled.is_zero();
1335        }
1336    }
1337    false
1338}
1339
1340/// Parses a single OKX order message into an [`ExecutionReport`].
1341///
1342/// # Errors
1343///
1344/// Returns an error if the instrument cannot be found or if parsing the
1345/// underlying order payload fails.
1346pub fn parse_order_msg(
1347    msg: &OKXOrderMsg,
1348    account_id: AccountId,
1349    instruments: &AHashMap<Ustr, InstrumentAny>,
1350    fee_cache: &AHashMap<Ustr, Money>,
1351    filled_qty_cache: &AHashMap<Ustr, Quantity>,
1352    ts_init: UnixNanos,
1353) -> anyhow::Result<ExecutionReport> {
1354    let instrument = instruments
1355        .get(&msg.inst_id)
1356        .ok_or_else(|| anyhow::anyhow!("No instrument found for inst_id: {}", msg.inst_id))?;
1357
1358    let previous_fee = fee_cache.get(&msg.ord_id).copied();
1359    let previous_filled_qty = filled_qty_cache.get(&msg.ord_id).copied();
1360
1361    let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0")
1362        || !msg.trade_id.is_empty()
1363        || has_acc_fill_sz_increased(
1364            &msg.acc_fill_sz,
1365            previous_filled_qty,
1366            instrument.size_precision(),
1367        );
1368
1369    warn_unrecognized_order_state(msg);
1370
1371    match msg.state {
1372        OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled | OKXOrderStatus::Unknown
1373            if has_new_fill =>
1374        {
1375            match parse_fill_report(
1376                msg,
1377                instrument,
1378                account_id,
1379                previous_fee,
1380                previous_filled_qty,
1381                ts_init,
1382            )? {
1383                Some(report) => Ok(ExecutionReport::Fill(report)),
1384                None => parse_order_status_report(msg, instrument, account_id, ts_init)
1385                    .map(ExecutionReport::Order),
1386            }
1387        }
1388        _ => parse_order_status_report(msg, instrument, account_id, ts_init)
1389            .map(ExecutionReport::Order),
1390    }
1391}
1392
1393/// Logs a warning when an order message carries a state this build does not
1394/// recognize. Fill data on the message is still processed; only the status
1395/// classification is skipped.
1396fn warn_unrecognized_order_state(msg: &OKXOrderMsg) {
1397    if msg.state == OKXOrderStatus::Unknown {
1398        log::warn!(
1399            "Unrecognized order state: order_id={}, inst_id={}, processing any fill data and skipping status classification",
1400            msg.ord_id,
1401            msg.inst_id,
1402        );
1403    }
1404}
1405
1406/// Parses a single OKX spread order message into an [`ExecutionReport`].
1407///
1408/// # Errors
1409///
1410/// Returns an error if the instrument cannot be found or if parsing the
1411/// underlying order payload fails.
1412pub fn parse_spread_order_msg(
1413    msg: &OKXSpreadOrder,
1414    account_id: AccountId,
1415    instruments: &AHashMap<Ustr, InstrumentAny>,
1416    filled_qty_cache: &AHashMap<Ustr, Quantity>,
1417    ts_init: UnixNanos,
1418) -> anyhow::Result<ExecutionReport> {
1419    let instrument = instruments
1420        .get(&msg.sprd_id)
1421        .ok_or_else(|| anyhow::anyhow!("No instrument found for sprd_id: {}", msg.sprd_id))?;
1422    let previous_filled_qty = filled_qty_cache.get(&msg.ord_id).copied();
1423    let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0")
1424        || !msg.trade_id.is_empty()
1425        || has_acc_fill_sz_increased_value(
1426            Some(msg.acc_fill_sz.as_str()),
1427            previous_filled_qty,
1428            instrument.size_precision(),
1429        );
1430
1431    match msg.state {
1432        OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled if has_new_fill => {
1433            match parse_spread_order_fill_report(
1434                msg,
1435                instrument,
1436                account_id,
1437                previous_filled_qty,
1438                ts_init,
1439            )? {
1440                Some(report) => Ok(ExecutionReport::Fill(report)),
1441                None => parse_common_spread_order_status_report(
1442                    msg,
1443                    account_id,
1444                    instrument.id(),
1445                    instrument.price_precision(),
1446                    instrument.size_precision(),
1447                    ts_init,
1448                )
1449                .map(ExecutionReport::Order),
1450            }
1451        }
1452        _ => parse_common_spread_order_status_report(
1453            msg,
1454            account_id,
1455            instrument.id(),
1456            instrument.price_precision(),
1457            instrument.size_precision(),
1458            ts_init,
1459        )
1460        .map(ExecutionReport::Order),
1461    }
1462}
1463
1464/// Parses an OKX algo order message into a Nautilus execution report.
1465///
1466/// # Errors
1467///
1468/// Returns an error if the instrument cannot be found or if message fields
1469/// fail to parse.
1470pub fn parse_algo_order_msg(
1471    msg: &OKXAlgoOrderMsg,
1472    account_id: AccountId,
1473    instruments: &AHashMap<Ustr, InstrumentAny>,
1474    ts_init: UnixNanos,
1475) -> anyhow::Result<Option<ExecutionReport>> {
1476    // Skip unsupported algo types (iceberg, twap, chase); their triggered child
1477    // orders still arrive on the regular orders channel
1478    if matches!(
1479        msg.ord_type,
1480        OKXAlgoOrderType::Iceberg | OKXAlgoOrderType::Twap | OKXAlgoOrderType::Chase
1481    ) {
1482        log::debug!("Skipping unsupported algo order type: {:?}", msg.ord_type);
1483        return Ok(None);
1484    }
1485
1486    if msg.ord_type == OKXAlgoOrderType::Other {
1487        log::warn!(
1488            "Skipping algo order with unrecognized order type: algo_id={}, inst_id={}",
1489            msg.algo_id,
1490            msg.inst_id,
1491        );
1492        return Ok(None);
1493    }
1494
1495    if msg.state == OKXAlgoOrderStatus::Unknown {
1496        log::warn!(
1497            "Skipping algo order with unrecognized state: algo_id={}, inst_id={}",
1498            msg.algo_id,
1499            msg.inst_id,
1500        );
1501        return Ok(None);
1502    }
1503
1504    let inst = instruments
1505        .get(&msg.inst_id)
1506        .ok_or_else(|| anyhow::anyhow!("No instrument found for inst_id: {}", msg.inst_id))?;
1507
1508    parse_algo_order_status_report(msg, inst, account_id, ts_init)
1509        .map(ExecutionReport::Order)
1510        .map(Some)
1511}
1512
1513/// Parses an OKX algo order message into a Nautilus order status report.
1514///
1515/// # Errors
1516///
1517/// Returns an error if any order identifiers or numeric fields cannot be
1518/// parsed.
1519pub fn parse_algo_order_status_report(
1520    msg: &OKXAlgoOrderMsg,
1521    instrument: &InstrumentAny,
1522    account_id: AccountId,
1523    ts_init: UnixNanos,
1524) -> anyhow::Result<OrderStatusReport> {
1525    let client_order_id = parse_parent_client_order_id(Some(&msg.algo_cl_ord_id), &msg.cl_ord_id);
1526
1527    // For algo orders that haven't triggered, ord_id will be empty, use algo_id instead
1528    let venue_order_id = if msg.ord_id.is_empty() {
1529        VenueOrderId::new(msg.algo_id.as_str())
1530    } else {
1531        VenueOrderId::new(msg.ord_id.as_str())
1532    };
1533
1534    let order_side = OrderSide::from(msg.side);
1535
1536    let algo_fields = parse_algo_order_fields(msg)?;
1537
1538    let status: OrderStatus = msg
1539        .state
1540        .try_into()
1541        .map_err(|e| anyhow::anyhow!("Unsupported OKX algo order status: {e}"))?;
1542
1543    let quantity = parse_algo_order_quantity(msg, instrument)?;
1544
1545    let filled_qty = if msg.state == OKXAlgoOrderStatus::Filled
1546        && !msg.actual_sz.is_empty()
1547        && msg.actual_sz != "0"
1548    {
1549        parse_quantity(msg.actual_sz.as_str(), instrument.size_precision())?
1550    } else {
1551        Quantity::zero(instrument.size_precision())
1552    };
1553
1554    // Parse limit price if it exists (not -1)
1555    let price = if is_market_price(algo_fields.ord_px) {
1556        None
1557    } else {
1558        Some(parse_price(
1559            algo_fields.ord_px,
1560            instrument.price_precision(),
1561        )?)
1562    };
1563
1564    let trigger_type = match algo_fields.trigger_px_type {
1565        OKXTriggerType::Last => TriggerType::LastPrice,
1566        OKXTriggerType::Mark => TriggerType::MarkPrice,
1567        OKXTriggerType::Index => TriggerType::IndexPrice,
1568        OKXTriggerType::None => TriggerType::Default,
1569    };
1570
1571    let ts_accepted = parse_millisecond_timestamp(msg.c_time);
1572    let ts_last = parse_millisecond_timestamp(msg.u_time);
1573
1574    let mut report = OrderStatusReport::new(
1575        account_id,
1576        instrument.id(),
1577        client_order_id,
1578        venue_order_id,
1579        order_side.into(),
1580        algo_fields.order_type,
1581        TimeInForce::Gtc,
1582        status,
1583        quantity,
1584        filled_qty,
1585        ts_accepted,
1586        ts_last,
1587        ts_init,
1588        None,
1589    );
1590
1591    if !algo_fields.trigger_px.is_empty() {
1592        report.trigger_price = Some(parse_price(
1593            algo_fields.trigger_px,
1594            instrument.price_precision(),
1595        )?);
1596    }
1597
1598    report.trigger_type = Some(trigger_type);
1599
1600    if let Some(limit_price) = price {
1601        report.price = Some(limit_price);
1602    }
1603
1604    if algo_fields.order_type == OrderType::TrailingStopMarket {
1605        if !msg.callback_ratio.is_empty() {
1606            // OKX ratio is e.g. "0.01" for 1%, convert to basis points
1607            let ratio = Decimal::from_str(&msg.callback_ratio)?;
1608            report.trailing_offset = Some(ratio * Decimal::new(10_000, 0));
1609            report.trailing_offset_type = Some(TrailingOffsetType::BasisPoints);
1610        } else if !msg.callback_spread.is_empty() {
1611            report.trailing_offset = Some(Decimal::from_str(&msg.callback_spread)?);
1612            report.trailing_offset_type = Some(TrailingOffsetType::Price);
1613        }
1614
1615        if !msg.active_px.is_empty() {
1616            report.activation_price =
1617                Some(parse_price(&msg.active_px, instrument.price_precision())?);
1618        }
1619    }
1620
1621    if msg.reduce_only == "true" {
1622        report = report.with_reduce_only(true);
1623    }
1624
1625    Ok(report)
1626}
1627
1628struct AlgoOrderFields<'a> {
1629    order_type: OrderType,
1630    trigger_px: &'a str,
1631    trigger_px_type: OKXTriggerType,
1632    ord_px: &'a str,
1633}
1634
1635fn parse_algo_order_fields(msg: &OKXAlgoOrderMsg) -> anyhow::Result<AlgoOrderFields<'_>> {
1636    match msg.ord_type {
1637        OKXAlgoOrderType::MoveOrderStop => Ok(AlgoOrderFields {
1638            order_type: OrderType::TrailingStopMarket,
1639            trigger_px: msg.trigger_px.as_str(),
1640            trigger_px_type: msg.trigger_px_type,
1641            ord_px: msg.ord_px.as_str(),
1642        }),
1643        OKXAlgoOrderType::Conditional | OKXAlgoOrderType::Oco => {
1644            if msg.tp_trigger_px.is_empty() {
1645                let (trigger_px, trigger_px_type, ord_px) = if msg.sl_trigger_px.is_empty() {
1646                    (
1647                        msg.trigger_px.as_str(),
1648                        msg.trigger_px_type,
1649                        msg.ord_px.as_str(),
1650                    )
1651                } else {
1652                    (
1653                        msg.sl_trigger_px.as_str(),
1654                        msg.sl_trigger_px_type,
1655                        msg.sl_ord_px.as_str(),
1656                    )
1657                };
1658
1659                Ok(AlgoOrderFields {
1660                    order_type: if is_market_price(ord_px) {
1661                        OrderType::StopMarket
1662                    } else {
1663                        OrderType::StopLimit
1664                    },
1665                    trigger_px,
1666                    trigger_px_type,
1667                    ord_px,
1668                })
1669            } else {
1670                let ord_px = msg.tp_ord_px.as_str();
1671                Ok(AlgoOrderFields {
1672                    order_type: if is_market_price(ord_px) {
1673                        OrderType::MarketIfTouched
1674                    } else {
1675                        OrderType::LimitIfTouched
1676                    },
1677                    trigger_px: msg.tp_trigger_px.as_str(),
1678                    trigger_px_type: msg.tp_trigger_px_type,
1679                    ord_px,
1680                })
1681            }
1682        }
1683        OKXAlgoOrderType::Trigger => Ok(AlgoOrderFields {
1684            order_type: if is_market_price(&msg.ord_px) {
1685                OrderType::StopMarket
1686            } else {
1687                OrderType::StopLimit
1688            },
1689            trigger_px: msg.trigger_px.as_str(),
1690            trigger_px_type: msg.trigger_px_type,
1691            ord_px: msg.ord_px.as_str(),
1692        }),
1693        _ => anyhow::bail!("Unsupported algo order type: {:?}", msg.ord_type),
1694    }
1695}
1696
1697fn parse_algo_order_quantity(
1698    msg: &OKXAlgoOrderMsg,
1699    instrument: &InstrumentAny,
1700) -> anyhow::Result<Quantity> {
1701    if !msg.sz.is_empty() {
1702        return parse_quantity(msg.sz.as_str(), instrument.size_precision());
1703    }
1704
1705    if !msg.close_fraction.is_empty()
1706        || !msg.sl_trigger_px.is_empty()
1707        || !msg.tp_trigger_px.is_empty()
1708    {
1709        return Ok(Quantity::zero(instrument.size_precision()));
1710    }
1711
1712    anyhow::bail!("Missing sz for algo order {}", msg.algo_id)
1713}
1714
1715/// Parses an OKX order message into a Nautilus order status report.
1716///
1717/// # Errors
1718///
1719/// Returns an error if order metadata or numeric values cannot be parsed.
1720pub fn parse_order_status_report(
1721    msg: &OKXOrderMsg,
1722    instrument: &InstrumentAny,
1723    account_id: AccountId,
1724    ts_init: UnixNanos,
1725) -> anyhow::Result<OrderStatusReport> {
1726    let client_order_id =
1727        parse_parent_client_order_id(msg.algo_cl_ord_id.as_deref(), &msg.cl_ord_id);
1728    let venue_order_id = VenueOrderId::new(msg.ord_id);
1729    let order_side = OrderSide::from(msg.side);
1730
1731    let okx_order_type = msg.ord_type;
1732
1733    // Determine order type based on presence of limit price for certain OKX order types
1734    let order_type = match okx_order_type {
1735        OKXOrderType::Trigger => {
1736            if is_market_price(&msg.px) {
1737                OrderType::StopMarket
1738            } else {
1739                OrderType::StopLimit
1740            }
1741        }
1742        OKXOrderType::Fok | OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => {
1743            determine_order_type_with_alt(
1744                okx_order_type,
1745                &msg.px,
1746                msg.px_vol.as_deref().unwrap_or(""),
1747                msg.px_usd.as_deref().unwrap_or(""),
1748            )?
1749        }
1750        other => other
1751            .try_into()
1752            .map_err(|e| anyhow::anyhow!("Unsupported OKX order type: {e}"))?,
1753    };
1754    let order_status: OrderStatus = msg
1755        .state
1756        .try_into()
1757        .map_err(|e| anyhow::anyhow!("Unsupported OKX order status: {e}"))?;
1758
1759    let time_in_force = match okx_order_type {
1760        OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
1761        OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
1762        _ => TimeInForce::Gtc,
1763    };
1764
1765    let size_precision = instrument.size_precision();
1766
1767    // Parse quantities based on target currency
1768    // OKX always returns acc_fill_sz in base currency, but sz depends on tgt_ccy
1769
1770    // Determine if this is a quote-quantity order
1771    // Method 1: Explicit tgt_ccy field set to QuoteCcy
1772    let is_quote_qty_explicit = msg.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
1773
1774    // Method 2: Use OKX defaults when tgt_ccy is None (old orders or missing field)
1775    // OKX API defaults for SPOT market orders: BUY orders use quote_ccy, SELL orders use base_ccy
1776    // Note: tgtCcy only applies to SPOT market orders (not limit orders)
1777    // For limit orders, sz is always in base currency regardless of side
1778    let is_quote_qty_heuristic = msg.tgt_ccy.is_none()
1779        && (msg.inst_type == OKXInstrumentType::Spot || msg.inst_type == OKXInstrumentType::Margin)
1780        && msg.side == OKXSide::Buy
1781        && order_type == OrderType::Market;
1782
1783    let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
1784        // Quote-quantity order: sz is in quote currency, need to convert to base
1785        let sz_quote_dec = Decimal::from_str(&msg.sz).map_err(|e| {
1786            anyhow::anyhow!("Failed to parse sz='{}' as quote quantity: {}", msg.sz, e)
1787        })?;
1788
1789        // Determine the price to use for conversion
1790        // Priority: 1) limit price (px) for limit orders, 2) avg_px for market orders
1791        let conversion_price_dec =
1792            if !is_market_price(&msg.px) {
1793                // Limit order: use the limit price (msg.px)
1794                Some(
1795                    Decimal::from_str(&msg.px)
1796                        .map_err(|e| anyhow::anyhow!("Failed to parse px='{}': {}", msg.px, e))?,
1797                )
1798            } else if !msg.avg_px.is_empty() && msg.avg_px != "0" {
1799                // Market order with fills: use average fill price
1800                Some(Decimal::from_str(&msg.avg_px).map_err(|e| {
1801                    anyhow::anyhow!("Failed to parse avg_px='{}': {}", msg.avg_px, e)
1802                })?)
1803            } else {
1804                None
1805            };
1806
1807        // Convert quote quantity to base: quantity_base = sz_quote / price
1808        let quantity_base = if let Some(price) = conversion_price_dec {
1809            if price.is_zero() {
1810                parse_quantity(&msg.sz, size_precision)?
1811            } else {
1812                Quantity::from_decimal_dp(sz_quote_dec / price, size_precision)?
1813            }
1814        } else {
1815            // No price available, can't convert - use sz as-is temporarily
1816            // This will be corrected once the order gets filled and price is available
1817            parse_quantity(&msg.sz, size_precision)?
1818        };
1819
1820        let filled_qty = parse_quantity(msg.acc_fill_sz.as_deref().unwrap_or(""), size_precision)?;
1821
1822        (quantity_base, filled_qty)
1823    } else {
1824        // Base-quantity order: both sz and acc_fill_sz are in base currency
1825        let quantity = parse_quantity(&msg.sz, size_precision)?;
1826        let filled_qty = parse_quantity(msg.acc_fill_sz.as_deref().unwrap_or(""), size_precision)?;
1827
1828        (quantity, filled_qty)
1829    };
1830
1831    // For quote-quantity orders marked as FILLED, adjust quantity to match filled_qty
1832    // to avoid precision mismatches from quote-to-base conversion
1833    let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
1834        && msg.state == OKXOrderStatus::Filled
1835        && filled_qty.is_positive()
1836    {
1837        (filled_qty, filled_qty)
1838    } else {
1839        (quantity, filled_qty)
1840    };
1841
1842    let ts_accepted = parse_millisecond_timestamp(msg.c_time);
1843    let ts_last = parse_millisecond_timestamp(msg.u_time);
1844
1845    let is_liquidation = matches!(
1846        msg.category,
1847        OKXOrderCategory::FullLiquidation | OKXOrderCategory::PartialLiquidation
1848    );
1849
1850    let is_adl = msg.category == OKXOrderCategory::Adl;
1851
1852    if is_liquidation {
1853        log::warn!(
1854            "Liquidation order status update: order_id={}, category={:?}, inst_id={}, state={:?}",
1855            msg.ord_id.as_str(),
1856            msg.category,
1857            msg.inst_id.as_str(),
1858            msg.state,
1859        );
1860    }
1861
1862    if is_adl {
1863        log::warn!(
1864            "ADL (Auto-Deleveraging) order status update: order_id={}, inst_id={}, state={:?}",
1865            msg.ord_id.as_str(),
1866            msg.inst_id.as_str(),
1867            msg.state,
1868        );
1869    }
1870
1871    let mut report = OrderStatusReport::new(
1872        account_id,
1873        instrument.id(),
1874        client_order_id,
1875        venue_order_id,
1876        order_side.into(),
1877        order_type,
1878        time_in_force,
1879        order_status,
1880        quantity,
1881        filled_qty,
1882        ts_accepted,
1883        ts_last,
1884        ts_init,
1885        None, // Generate UUID4 automatically
1886    );
1887
1888    let price_precision = instrument.price_precision();
1889
1890    if okx_order_type == OKXOrderType::Trigger {
1891        // For triggered orders coming through regular orders channel,
1892        // set the price if it's a stop-limit order
1893        if !is_market_price(&msg.px)
1894            && let Ok(price) = parse_price(&msg.px, price_precision)
1895        {
1896            report = report.with_price(price);
1897        }
1898    } else {
1899        // For regular orders, use px field
1900        if !is_market_price(&msg.px)
1901            && let Ok(price) = parse_price(&msg.px, price_precision)
1902        {
1903            report = report.with_price(price);
1904        }
1905    }
1906
1907    if !msg.avg_px.is_empty()
1908        && let Ok(decimal) = Decimal::from_str(&msg.avg_px)
1909    {
1910        report.avg_px = Some(decimal);
1911    }
1912
1913    if matches!(
1914        msg.ord_type,
1915        OKXOrderType::PostOnly | OKXOrderType::Rpi | OKXOrderType::MmpAndPostOnly
1916    ) || matches!(
1917        msg.cancel_source.as_deref(),
1918        Some(source) if source == OKX_POST_ONLY_CANCEL_SOURCE
1919    ) || matches!(
1920        msg.cancel_source_reason.as_deref(),
1921        Some(reason) if reason.contains("POST_ONLY")
1922    ) {
1923        report = report.with_post_only(true);
1924    }
1925
1926    if msg.reduce_only == "true" {
1927        report = report.with_reduce_only(true);
1928    }
1929
1930    let mut linked_ids = Vec::new();
1931
1932    if let Some(algo_cl_ord_id) = msg
1933        .algo_cl_ord_id
1934        .as_ref()
1935        .filter(|value| !value.is_empty())
1936    {
1937        let algo_client_id = ClientOrderId::new(algo_cl_ord_id.as_str());
1938        if report.client_order_id != Some(algo_client_id) {
1939            linked_ids.push(algo_client_id);
1940        }
1941    }
1942
1943    if let Some(attach_algo_cl_ord_id) = msg
1944        .attach_algo_cl_ord_id
1945        .as_ref()
1946        .filter(|value| !value.is_empty())
1947    {
1948        let attach_client_id = ClientOrderId::new(attach_algo_cl_ord_id.as_str());
1949        if report.client_order_id != Some(attach_client_id)
1950            && !linked_ids.contains(&attach_client_id)
1951        {
1952            linked_ids.push(attach_client_id);
1953        }
1954    }
1955
1956    for attach_algo in &msg.attach_algo_ords {
1957        if attach_algo.attach_algo_cl_ord_id.is_empty() {
1958            continue;
1959        }
1960
1961        let attach_client_id = ClientOrderId::new(attach_algo.attach_algo_cl_ord_id.as_str());
1962        if report.client_order_id != Some(attach_client_id)
1963            && !linked_ids.contains(&attach_client_id)
1964        {
1965            linked_ids.push(attach_client_id);
1966        }
1967    }
1968
1969    if !linked_ids.is_empty() {
1970        report = report.with_linked_order_ids(linked_ids);
1971    }
1972
1973    if let Some(reason) = msg
1974        .cancel_source_reason
1975        .as_ref()
1976        .filter(|reason| !reason.is_empty())
1977    {
1978        report = report.with_cancel_reason(reason.clone());
1979    } else if let Some(source) = msg
1980        .cancel_source
1981        .as_ref()
1982        .filter(|source| !source.is_empty())
1983    {
1984        let reason = if source == OKX_POST_ONLY_CANCEL_SOURCE {
1985            OKX_POST_ONLY_CANCEL_REASON.to_string()
1986        } else {
1987            format!("cancel_source={source}")
1988        };
1989        report = report.with_cancel_reason(reason);
1990    }
1991
1992    Ok(report)
1993}
1994
1995fn parse_spread_order_fill_report(
1996    msg: &OKXSpreadOrder,
1997    instrument: &InstrumentAny,
1998    _account_id: AccountId,
1999    previous_filled_qty: Option<Quantity>,
2000    _ts_init: UnixNanos,
2001) -> anyhow::Result<Option<FillReport>> {
2002    let size_precision = instrument.size_precision();
2003    if !msg.fill_sz.is_empty() && msg.fill_sz != "0" {
2004        parse_quantity(&msg.fill_sz, size_precision).map_err(|e| {
2005            anyhow::anyhow!("Failed to parse spread fill_sz='{}': {e}", msg.fill_sz)
2006        })?;
2007    } else if !msg.acc_fill_sz.is_empty() && msg.acc_fill_sz != "0" {
2008        let current_filled = parse_quantity(&msg.acc_fill_sz, size_precision).map_err(|e| {
2009            anyhow::anyhow!(
2010                "Failed to parse spread acc_fill_sz='{}': {e}",
2011                msg.acc_fill_sz
2012            )
2013        })?;
2014
2015        if let Some(prev_qty) = previous_filled_qty {
2016            if current_filled < prev_qty {
2017                anyhow::bail!(
2018                    "Cumulative spread fill went backwards: acc_fill_sz='{}' < previous_filled_qty={} \
2019                     (possible stale data after reconnect)",
2020                    msg.acc_fill_sz,
2021                    prev_qty
2022                );
2023            }
2024
2025            if (current_filled - prev_qty).is_zero() {
2026                log::debug!(
2027                    "Skipping duplicate spread fill: acc_fill_sz='{}' unchanged from previous={}",
2028                    msg.acc_fill_sz,
2029                    prev_qty
2030                );
2031                return Ok(None);
2032            }
2033        }
2034    } else {
2035        anyhow::bail!(
2036            "Cannot determine spread fill quantity: fill_sz='{}' and acc_fill_sz='{}'",
2037            msg.fill_sz,
2038            msg.acc_fill_sz
2039        );
2040    }
2041
2042    anyhow::bail!(
2043        "missing fee for spread fill report sprd_id={}; OKX sprd-orders updates omit fee",
2044        msg.sprd_id
2045    )
2046}
2047
2048/// Parses an OKX order message into a Nautilus fill report.
2049///
2050/// # Errors
2051///
2052/// Returns an error if order quantities, prices, or fees cannot be parsed.
2053pub fn parse_fill_report(
2054    msg: &OKXOrderMsg,
2055    instrument: &InstrumentAny,
2056    account_id: AccountId,
2057    previous_fee: Option<Money>,
2058    previous_filled_qty: Option<Quantity>,
2059    ts_init: UnixNanos,
2060) -> anyhow::Result<Option<FillReport>> {
2061    let client_order_id =
2062        parse_parent_client_order_id(msg.algo_cl_ord_id.as_deref(), &msg.cl_ord_id);
2063    let venue_order_id = VenueOrderId::new(msg.ord_id);
2064
2065    // OKX may not provide a `trade_id` (some algo trigger payloads, manual
2066    // settlements). Derive a deterministic id from the immutable fill fields
2067    // via FNV-1a so reconnect replays of the same fill collapse to one event
2068    // in the downstream `WsDispatchState::check_and_insert_trade` dedup. A
2069    // random UUID4 here would defeat dedup, since each replay would mint a
2070    // new id. The hash output keeps the synthesized id within `TradeId`'s
2071    // 36-character cap.
2072    let trade_id = if msg.trade_id.is_empty() {
2073        let synthetic = synthesize_trade_id(msg);
2074        TradeId::new(&synthetic)
2075    } else {
2076        TradeId::new(&msg.trade_id)
2077    };
2078
2079    let order_side = OrderSide::from(msg.side);
2080
2081    let price_precision = instrument.price_precision();
2082    let size_precision = instrument.size_precision();
2083
2084    let price_str = if !msg.fill_px.is_empty() {
2085        &msg.fill_px
2086    } else if !msg.avg_px.is_empty() {
2087        &msg.avg_px
2088    } else {
2089        &msg.px
2090    };
2091    let last_px = parse_price(price_str, price_precision).map_err(|e| {
2092        anyhow::anyhow!(
2093            "Failed to parse price (fill_px='{}', avg_px='{}', px='{}'): {}",
2094            msg.fill_px,
2095            msg.avg_px,
2096            msg.px,
2097            e
2098        )
2099    })?;
2100
2101    // OKX provides fillSz (incremental fill) or accFillSz (cumulative total)
2102    // If fillSz is provided, use it directly as the incremental fill quantity
2103    let last_qty = if !msg.fill_sz.is_empty() && msg.fill_sz != "0" {
2104        parse_quantity(&msg.fill_sz, size_precision)
2105            .map_err(|e| anyhow::anyhow!("Failed to parse fill_sz='{}': {e}", msg.fill_sz,))?
2106    } else if let Some(ref acc_fill_sz) = msg.acc_fill_sz {
2107        // If fillSz is missing but accFillSz is available, calculate incremental fill
2108        if !acc_fill_sz.is_empty() && acc_fill_sz != "0" {
2109            let current_filled = parse_quantity(acc_fill_sz, size_precision).map_err(|e| {
2110                anyhow::anyhow!("Failed to parse acc_fill_sz='{acc_fill_sz}': {e}",)
2111            })?;
2112
2113            // Calculate incremental fill as: current_total - previous_total
2114            if let Some(prev_qty) = previous_filled_qty {
2115                if current_filled < prev_qty {
2116                    anyhow::bail!(
2117                        "Cumulative fill went backwards: acc_fill_sz='{acc_fill_sz}' < previous_filled_qty={prev_qty} \
2118                         (possible stale data after reconnect)"
2119                    );
2120                }
2121                let incremental = current_filled - prev_qty;
2122                if incremental.is_zero() {
2123                    log::debug!(
2124                        "Skipping duplicate fill: acc_fill_sz='{acc_fill_sz}' unchanged from previous={prev_qty}"
2125                    );
2126                    return Ok(None);
2127                }
2128                incremental
2129            } else {
2130                // First fill, use accumulated as incremental
2131                current_filled
2132            }
2133        } else {
2134            anyhow::bail!(
2135                "Cannot determine fill quantity: fill_sz is empty/zero and acc_fill_sz is empty/zero"
2136            );
2137        }
2138    } else {
2139        anyhow::bail!(
2140            "Cannot determine fill quantity: fill_sz='{}' and acc_fill_sz is None",
2141            msg.fill_sz
2142        );
2143    };
2144
2145    let fee_str = msg
2146        .fee
2147        .as_deref()
2148        .filter(|fee| !fee.trim().is_empty())
2149        .ok_or_else(|| anyhow::anyhow!("missing fee for fill report inst_id={}", msg.inst_id))?;
2150    let fee_dec = Decimal::from_str(fee_str)
2151        .map_err(|e| anyhow::anyhow!("Failed to parse fee '{fee_str}': {e}"))?;
2152
2153    let fee_currency = parse_fee_currency(msg.fee_ccy.as_str(), fee_dec, || {
2154        format!("fill report for inst_id={}", msg.inst_id)
2155    });
2156
2157    // OKX sends fees as negative numbers (e.g., "-2.5" for a $2.5 charge), parse_fee negates to positive
2158    let total_fee = parse_fee(Some(fee_str), fee_currency)
2159        .map_err(|e| anyhow::anyhow!("Failed to parse fee={:?}: {}", msg.fee, e))?;
2160
2161    // OKX sends cumulative fees, so we subtract the previous total to get this fill's fee
2162    let commission = if let Some(previous_fee) = previous_fee {
2163        if total_fee.currency == previous_fee.currency {
2164            let incremental = total_fee - previous_fee;
2165
2166            if incremental < Money::zero(fee_currency) {
2167                log::debug!(
2168                    "Negative incremental fee detected - likely a maker rebate or fee refund: order_id={}, total_fee={}, previous_fee={}, incremental={}",
2169                    msg.ord_id.as_str(),
2170                    total_fee,
2171                    previous_fee,
2172                    incremental,
2173                );
2174            }
2175
2176            // Skip corruption check when previous is negative (rebate), as transitions from
2177            // rebate to charge legitimately have incremental > total (e.g., -1 → +2 gives +3)
2178            if previous_fee >= Money::zero(fee_currency)
2179                && total_fee > Money::zero(fee_currency)
2180                && incremental > total_fee
2181            {
2182                log::error!(
2183                    "Incremental fee exceeds total fee - likely fee cache corruption, using total fee as fallback: order_id={}, total_fee={}, previous_fee={}, incremental={}",
2184                    msg.ord_id.as_str(),
2185                    total_fee,
2186                    previous_fee,
2187                    incremental,
2188                );
2189                total_fee
2190            } else {
2191                incremental
2192            }
2193        } else {
2194            log::warn!(
2195                "Fee currency changed from {} to {} for order_id={}, using total fee as commission",
2196                previous_fee.currency.code,
2197                total_fee.currency.code,
2198                msg.ord_id.as_str(),
2199            );
2200            total_fee
2201        }
2202    } else {
2203        total_fee
2204    };
2205
2206    let liquidity_side: LiquiditySide = msg.exec_type.into();
2207    let ts_event = parse_millisecond_timestamp(msg.fill_time);
2208
2209    let is_liquidation = matches!(
2210        msg.category,
2211        OKXOrderCategory::FullLiquidation | OKXOrderCategory::PartialLiquidation
2212    );
2213
2214    let is_adl = msg.category == OKXOrderCategory::Adl;
2215
2216    if is_liquidation {
2217        log::warn!(
2218            "Liquidation order detected: order_id={}, category={:?}, inst_id={}, side={:?}, fill_sz={}, fill_px={}",
2219            msg.ord_id.as_str(),
2220            msg.category,
2221            msg.inst_id.as_str(),
2222            msg.side,
2223            msg.fill_sz,
2224            msg.fill_px,
2225        );
2226    }
2227
2228    if is_adl {
2229        log::warn!(
2230            "ADL (Auto-Deleveraging) order detected: order_id={}, inst_id={}, side={:?}, fill_sz={}, fill_px={}",
2231            msg.ord_id.as_str(),
2232            msg.inst_id.as_str(),
2233            msg.side,
2234            msg.fill_sz,
2235            msg.fill_px,
2236        );
2237    }
2238
2239    let report = FillReport::new(
2240        account_id,
2241        instrument.id(),
2242        venue_order_id,
2243        trade_id,
2244        order_side,
2245        last_qty,
2246        last_px,
2247        commission,
2248        liquidity_side,
2249        client_order_id,
2250        None,
2251        ts_event,
2252        ts_init,
2253        None, // Generate UUID4 automatically
2254    );
2255
2256    Ok(Some(report))
2257}
2258
2259/// Parses an option summary payload into [`OptionGreeks`].
2260///
2261/// Selects Black-Scholes (`delta_bs`, `gamma_bs`, `vega_bs`, `theta_bs`) or
2262/// price-adjusted (`delta`, `gamma`, `vega`, `theta`) greeks based on `greeks_type`.
2263/// BS greeks align with what Deribit and Bybit provide; PA greeks are denominated
2264/// in the underlying/coin units and match OKX's native contract convention.
2265///
2266/// # Errors
2267///
2268/// Returns an error if any of the greeks or volatility fields cannot be parsed as f64.
2269pub fn parse_option_summary_greeks(
2270    msg: &OKXOptionSummaryMsg,
2271    instrument_id: &InstrumentId,
2272    greeks_type: OKXGreeksType,
2273    ts_init: UnixNanos,
2274) -> anyhow::Result<OptionGreeks> {
2275    let ts_event = UnixNanos::from(msg.ts * 1_000_000);
2276
2277    let (delta_s, gamma_s, vega_s, theta_s, delta_ctx, gamma_ctx, vega_ctx, theta_ctx) =
2278        match greeks_type {
2279            OKXGreeksType::Bs => (
2280                &msg.delta_bs,
2281                &msg.gamma_bs,
2282                &msg.vega_bs,
2283                &msg.theta_bs,
2284                "invalid delta_bs",
2285                "invalid gamma_bs",
2286                "invalid vega_bs",
2287                "invalid theta_bs",
2288            ),
2289            OKXGreeksType::Pa => (
2290                &msg.delta,
2291                &msg.gamma,
2292                &msg.vega,
2293                &msg.theta,
2294                "invalid delta (pa)",
2295                "invalid gamma (pa)",
2296                "invalid vega (pa)",
2297                "invalid theta (pa)",
2298            ),
2299        };
2300
2301    let delta: f64 = delta_s.parse().context(delta_ctx)?;
2302    let gamma: f64 = gamma_s.parse().context(gamma_ctx)?;
2303    let vega: f64 = vega_s.parse().context(vega_ctx)?;
2304    let theta: f64 = theta_s.parse().context(theta_ctx)?;
2305
2306    let bid_iv: f64 = msg.bid_vol.parse().context("invalid bid_vol")?;
2307    let ask_iv: f64 = msg.ask_vol.parse().context("invalid ask_vol")?;
2308    let mark_iv: f64 = msg.mark_vol.parse().context("invalid mark_vol")?;
2309
2310    let underlying_price = msg
2311        .fwd_px
2312        .as_deref()
2313        .filter(|s| !s.is_empty())
2314        .map(|s| s.parse::<f64>())
2315        .transpose()
2316        .context("invalid fwd_px")?;
2317
2318    Ok(OptionGreeks {
2319        instrument_id: *instrument_id,
2320        convention: greeks_type.into(),
2321        greeks: OptionGreekValues {
2322            delta,
2323            gamma,
2324            vega,
2325            theta,
2326            rho: 0.0, // OKX does not provide rho
2327        },
2328        mark_iv: Some(mark_iv),
2329        bid_iv: Some(bid_iv),
2330        ask_iv: Some(ask_iv),
2331        underlying_price,
2332        open_interest: None,
2333        ts_event,
2334        ts_init,
2335    })
2336}
2337
2338/// Parses OKX WebSocket message payloads into Nautilus data structures.
2339///
2340/// # Errors
2341///
2342/// Returns an error if the payload cannot be deserialized or if downstream
2343/// parsing routines fail.
2344///
2345/// # Panics
2346///
2347/// Panics only in the case where `okx_channel_to_bar_spec(channel)` returns
2348/// `None` after a prior `is_some` check - an unreachable scenario indicating a
2349/// logic error.
2350#[expect(clippy::too_many_arguments)]
2351pub fn parse_ws_message_data(
2352    channel: &OKXWsChannel,
2353    data: serde_json::Value,
2354    instrument_id: &InstrumentId,
2355    price_precision: u8,
2356    size_precision: u8,
2357    ts_init: UnixNanos,
2358    funding_cache: &mut AHashMap<Ustr, (Ustr, u64)>,
2359    instruments_cache: &AHashMap<Ustr, InstrumentAny>,
2360) -> anyhow::Result<Option<NautilusWsMessage>> {
2361    match channel {
2362        OKXWsChannel::Instruments => {
2363            if let Ok(msg) = serde_json::from_value::<OKXInstrument>(data) {
2364                let inst_key = msg.inst_id;
2365                let cached_instrument = instruments_cache.get(&inst_key);
2366                let (margin_init, margin_maint, maker_fee, taker_fee) = cached_instrument.map_or(
2367                    (None, None, None, None),
2368                    extract_fees_from_cached_instrument,
2369                );
2370                let instrument_id =
2371                    cached_instrument.map_or_else(|| parse_instrument_id(inst_key), |i| i.id());
2372
2373                let status_action = okx_status_to_market_action(msg.state);
2374                let status = InstrumentStatus::new(
2375                    instrument_id,
2376                    status_action,
2377                    ts_init,
2378                    ts_init,
2379                    None,
2380                    None,
2381                    Some(matches!(msg.state, OKXInstrumentStatus::Live)),
2382                    None,
2383                    None,
2384                );
2385
2386                match parse_instrument_any(
2387                    &msg,
2388                    margin_init,
2389                    margin_maint,
2390                    maker_fee,
2391                    taker_fee,
2392                    ts_init,
2393                ) {
2394                    Ok(Some(inst_any)) => Ok(Some(NautilusWsMessage::Instrument(
2395                        Box::new(inst_any),
2396                        Some(status),
2397                    ))),
2398                    Ok(None) => {
2399                        log::warn!("Empty instrument payload: {msg:?}");
2400                        Ok(Some(NautilusWsMessage::InstrumentStatus(status)))
2401                    }
2402                    Err(e) => {
2403                        log::warn!("Failed to parse instrument {inst_key}: {e}");
2404                        Ok(Some(NautilusWsMessage::InstrumentStatus(status)))
2405                    }
2406                }
2407            } else {
2408                anyhow::bail!("Failed to deserialize instrument payload")
2409            }
2410        }
2411        OKXWsChannel::BboTbt => {
2412            let data_vec = parse_quote_msg_vec(
2413                data,
2414                instrument_id,
2415                price_precision,
2416                size_precision,
2417                ts_init,
2418            )?;
2419            Ok(Some(NautilusWsMessage::Data(data_vec)))
2420        }
2421        OKXWsChannel::Tickers => {
2422            let data_vec = parse_ticker_msg_vec(
2423                data,
2424                instrument_id,
2425                price_precision,
2426                size_precision,
2427                ts_init,
2428            )?;
2429            Ok(Some(NautilusWsMessage::Data(data_vec)))
2430        }
2431        OKXWsChannel::Trades | OKXWsChannel::SprdPublicTrades => {
2432            let data_vec = parse_trade_msg_vec(
2433                data,
2434                instrument_id,
2435                price_precision,
2436                size_precision,
2437                ts_init,
2438            )?;
2439            Ok(Some(NautilusWsMessage::Data(data_vec)))
2440        }
2441        OKXWsChannel::MarkPrice => {
2442            let data_vec = parse_mark_price_msg_vec(data, instrument_id, price_precision, ts_init)?;
2443            Ok(Some(NautilusWsMessage::Data(data_vec)))
2444        }
2445        OKXWsChannel::IndexTickers => {
2446            let data_vec =
2447                parse_index_price_msg_vec(data, instrument_id, price_precision, ts_init)?;
2448            Ok(Some(NautilusWsMessage::Data(data_vec)))
2449        }
2450        OKXWsChannel::FundingRate => {
2451            let data_vec = parse_funding_rate_msg_vec(data, instrument_id, ts_init, funding_cache)?;
2452            Ok(Some(NautilusWsMessage::FundingRates(data_vec)))
2453        }
2454        OKXWsChannel::EventContractMarkets => Ok(Some(NautilusWsMessage::Raw(data))),
2455        channel if okx_channel_to_bar_spec(channel).is_some() => {
2456            let bar_spec = okx_channel_to_bar_spec(channel).expect("bar_spec checked above");
2457            let data_vec = parse_candle_msg_vec(
2458                data,
2459                instrument_id,
2460                price_precision,
2461                size_precision,
2462                bar_spec,
2463                ts_init,
2464            )?;
2465            Ok(Some(NautilusWsMessage::Data(data_vec)))
2466        }
2467        OKXWsChannel::Books
2468        | OKXWsChannel::BooksTbt
2469        | OKXWsChannel::Books5
2470        | OKXWsChannel::Books50Tbt => {
2471            if let Ok(book_msgs) = serde_json::from_value::<Vec<OKXBookMsg>>(data) {
2472                let data_vec = parse_book10_msg_vec(
2473                    book_msgs,
2474                    instrument_id,
2475                    price_precision,
2476                    size_precision,
2477                    ts_init,
2478                )?;
2479                Ok(Some(NautilusWsMessage::Data(data_vec)))
2480            } else {
2481                anyhow::bail!("Failed to deserialize Books channel data as Vec<OKXBookMsg>")
2482            }
2483        }
2484        _ => {
2485            log::warn!("Unsupported channel for message parsing: {channel:?}");
2486            Ok(None)
2487        }
2488    }
2489}
2490
2491#[cfg(test)]
2492mod tests {
2493    use ahash::AHashMap;
2494    use nautilus_core::nanos::UnixNanos;
2495    use nautilus_model::{
2496        data::bar::BAR_SPEC_1_DAY_LAST,
2497        enums::GreeksConvention,
2498        identifiers::{ClientOrderId, Symbol},
2499        instruments::CryptoPerpetual,
2500        types::Currency,
2501    };
2502    use rstest::rstest;
2503    use rust_decimal::Decimal;
2504    use rust_decimal_macros::dec;
2505    use ustr::Ustr;
2506
2507    use super::*;
2508    use crate::{
2509        OKXPositionSide,
2510        common::{
2511            enums::{
2512                OKXAlgoOrderStatus, OKXExecType, OKXInstrumentType, OKXMarginMode, OKXOrderType,
2513                OKXPriceType, OKXQuickMarginType, OKXSelfTradePreventionMode, OKXSide,
2514                OKXTradeMode,
2515            },
2516            parse::parse_account_state,
2517            testing::load_test_json,
2518        },
2519        http::models::OKXAccount,
2520        websocket::messages::{
2521            OKXAlgoOrderMsg, OKXAttachedAlgoOrd, OKXLiquidationWarningMsg, OKXWebSocketArg,
2522            OKXWsFrame,
2523        },
2524    };
2525
2526    fn create_stub_instrument() -> CryptoPerpetual {
2527        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2528        CryptoPerpetual::builder()
2529            .instrument_id(instrument_id)
2530            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
2531            .base_currency(Currency::BTC())
2532            .quote_currency(Currency::USDT())
2533            .settlement_currency(Currency::USDT())
2534            .is_inverse(false)
2535            .price_precision(2)
2536            .size_precision(8)
2537            .price_increment(Price::from("0.01"))
2538            .size_increment(Quantity::from("0.00000001"))
2539            .ts_event(UnixNanos::default())
2540            .ts_init(UnixNanos::default())
2541            .build()
2542            .unwrap()
2543    }
2544
2545    fn create_stub_order_msg(
2546        fill_sz: &str,
2547        acc_fill_sz: Option<String>,
2548        order_id: &str,
2549        trade_id: &str,
2550    ) -> OKXOrderMsg {
2551        OKXOrderMsg {
2552            acc_fill_sz,
2553            algo_id: None,
2554            avg_px: "50000.0".to_string(),
2555            c_time: 1746947317401,
2556            cancel_source: None,
2557            cancel_source_reason: None,
2558            category: OKXOrderCategory::Normal,
2559            ccy: Ustr::from("USDT"),
2560            cl_ord_id: "test_order_1".to_string(),
2561            algo_cl_ord_id: None,
2562            attach_algo_cl_ord_id: None,
2563            attach_algo_ords: Vec::new(),
2564            outcome: None,
2565            fee: Some("-1.0".to_string()),
2566            fee_ccy: Ustr::from("USDT"),
2567            fill_fee: None,
2568            fill_fee_ccy: None,
2569            fill_mark_px: None,
2570            fill_mark_vol: None,
2571            fill_px_vol: None,
2572            fill_px_usd: None,
2573            fill_fwd_px: None,
2574            fill_notional_usd: None,
2575            fill_pnl: None,
2576            fill_px: "50000.0".to_string(),
2577            fill_sz: fill_sz.to_string(),
2578            fill_time: 1746947317402,
2579            inst_id: Ustr::from("BTC-USDT-SWAP"),
2580            inst_type: OKXInstrumentType::Swap,
2581            is_tp_limit: None,
2582            lever: "2.0".to_string(),
2583            linked_algo_ord: None,
2584            notional_usd: None,
2585            ord_id: Ustr::from(order_id),
2586            ord_type: OKXOrderType::Market,
2587            pnl: "0".to_string(),
2588            pos_side: OKXPositionSide::Long,
2589            px: String::new(),
2590            px_type: OKXPriceType::None,
2591            px_usd: None,
2592            px_vol: None,
2593            quick_mgn_type: OKXQuickMarginType::None,
2594            rebate: None,
2595            rebate_ccy: None,
2596            reduce_only: "false".to_string(),
2597            side: OKXSide::Buy,
2598            sl_ord_px: None,
2599            sl_trigger_px: None,
2600            sl_trigger_px_type: None,
2601            source: None,
2602            state: OKXOrderStatus::PartiallyFilled,
2603            stp_id: None,
2604            stp_mode: OKXSelfTradePreventionMode::None,
2605            exec_type: OKXExecType::Taker,
2606            sz: "0.03".to_string(),
2607            tag: None,
2608            td_mode: OKXTradeMode::Isolated,
2609            tgt_ccy: None,
2610            tp_ord_px: None,
2611            tp_trigger_px: None,
2612            tp_trigger_px_type: None,
2613            trade_id: trade_id.to_string(),
2614            u_time: 1746947317402,
2615            amend_result: None,
2616            req_id: None,
2617            code: None,
2618            msg: None,
2619        }
2620    }
2621
2622    #[rstest]
2623    fn test_parse_books_snapshot() {
2624        let json_data = load_test_json("ws_books_snapshot.json");
2625        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2626        let (okx_books, action): (Vec<OKXBookMsg>, OKXBookAction) = match msg {
2627            OKXWsFrame::BookData { data, action, .. } => (data, action),
2628            _ => panic!("Expected a `BookData` variant"),
2629        };
2630
2631        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2632        let deltas = parse_book_msg(
2633            &okx_books[0],
2634            instrument_id,
2635            2,
2636            1,
2637            &action,
2638            UnixNanos::default(),
2639        )
2640        .unwrap();
2641
2642        assert_eq!(deltas.instrument_id, instrument_id);
2643        assert_eq!(deltas.deltas.len(), 16);
2644        assert_eq!(deltas.flags, 32);
2645        assert_eq!(deltas.sequence, 123456);
2646        assert_eq!(deltas.ts_event, UnixNanos::from(1597026383085000000));
2647        assert_eq!(deltas.ts_init, UnixNanos::default());
2648
2649        // Verify some individual deltas are parsed correctly
2650        assert!(!deltas.deltas.is_empty());
2651        // Snapshot should have both bid and ask deltas
2652        assert!(
2653            deltas
2654                .deltas
2655                .iter()
2656                .any(|d| d.order.side == OrderSide::Buy.into()),
2657            "Should have bid deltas"
2658        );
2659        assert!(
2660            deltas
2661                .deltas
2662                .iter()
2663                .any(|d| d.order.side == OrderSide::Sell.into()),
2664            "Should have ask deltas"
2665        );
2666    }
2667
2668    #[rstest]
2669    fn test_parse_books_update() {
2670        let json_data = load_test_json("ws_books_update.json");
2671        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2672        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2673        let (okx_books, action): (Vec<OKXBookMsg>, OKXBookAction) = match msg {
2674            OKXWsFrame::BookData { data, action, .. } => (data, action),
2675            _ => panic!("Expected a `BookData` variant"),
2676        };
2677
2678        let deltas = parse_book_msg(
2679            &okx_books[0],
2680            instrument_id,
2681            2,
2682            1,
2683            &action,
2684            UnixNanos::default(),
2685        )
2686        .unwrap();
2687
2688        assert_eq!(deltas.instrument_id, instrument_id);
2689        assert_eq!(deltas.deltas.len(), 16);
2690        assert_eq!(deltas.flags, 0);
2691        assert_eq!(deltas.sequence, 123457);
2692        assert_eq!(deltas.ts_event, UnixNanos::from(1597026383085000000));
2693        assert_eq!(deltas.ts_init, UnixNanos::default());
2694
2695        // Verify some individual deltas are parsed correctly
2696        assert!(!deltas.deltas.is_empty());
2697        // Update should also have both bid and ask deltas
2698        assert!(
2699            deltas
2700                .deltas
2701                .iter()
2702                .any(|d| d.order.side == OrderSide::Buy.into()),
2703            "Should have bid deltas"
2704        );
2705        assert!(
2706            deltas
2707                .deltas
2708                .iter()
2709                .any(|d| d.order.side == OrderSide::Sell.into()),
2710            "Should have ask deltas"
2711        );
2712    }
2713
2714    #[rstest]
2715    fn test_parse_rpi_books_update_uses_total_quantity_and_sequence() {
2716        let json_data = load_test_json("ws_books_rpi_update.json");
2717        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2718        let (data, action) = match msg {
2719            OKXWsFrame::RpiBookData { data, action, .. } => (data, action),
2720            _ => panic!("Expected an RPI book update"),
2721        };
2722        let instrument_id = InstrumentId::from("OMI-USD.OKX");
2723
2724        let deltas =
2725            parse_rpi_book_msg(&data[0], instrument_id, 7, 3, &action, UnixNanos::from(123))
2726                .unwrap();
2727
2728        assert_eq!(deltas.instrument_id, instrument_id);
2729        assert_eq!(deltas.deltas.len(), 2);
2730        assert_eq!(deltas.flags, 0);
2731        assert_eq!(deltas.sequence, 1_082_831_230);
2732        assert_eq!(deltas.ts_event, UnixNanos::from(1_785_406_443_903_000_000));
2733        assert_eq!(deltas.ts_init, UnixNanos::from(123));
2734        assert_eq!(deltas.deltas[0].action, BookAction::Delete);
2735        assert_eq!(deltas.deltas[0].order.side, OrderSide::Sell.into());
2736        assert_eq!(deltas.deltas[0].order.price, Price::from("0.0001617"));
2737        assert_eq!(deltas.deltas[0].order.size, Quantity::from("0"));
2738        assert_eq!(deltas.deltas[1].action, BookAction::Update);
2739        assert_eq!(deltas.deltas[1].order.side, OrderSide::Sell.into());
2740        assert_eq!(deltas.deltas[1].order.price, Price::from("0.0001625"));
2741        assert_eq!(deltas.deltas[1].order.size, Quantity::from("12324367.786"));
2742    }
2743
2744    #[rstest]
2745    fn test_parse_tickers() {
2746        let json_data = load_test_json("ws_tickers.json");
2747        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2748        let okx_tickers: Vec<OKXTickerMsg> = match msg {
2749            OKXWsFrame::Data { data, .. } => serde_json::from_value(data).unwrap(),
2750            _ => panic!("Expected a `Data` variant"),
2751        };
2752
2753        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2754        let trade =
2755            parse_ticker_msg(&okx_tickers[0], instrument_id, 2, 1, UnixNanos::default()).unwrap();
2756
2757        assert_eq!(trade.instrument_id, InstrumentId::from("BTC-USDT.OKX"));
2758        assert_eq!(trade.bid_price, Price::from("8888.88"));
2759        assert_eq!(trade.ask_price, Price::from("9999.99"));
2760        assert_eq!(trade.bid_size, Quantity::from(5));
2761        assert_eq!(trade.ask_size, Quantity::from(11));
2762        assert_eq!(trade.ts_event, UnixNanos::from(1597026383085000000));
2763        assert_eq!(trade.ts_init, UnixNanos::default());
2764    }
2765
2766    #[rstest]
2767    fn test_parse_quotes() {
2768        let json_data = load_test_json("ws_bbo_tbt.json");
2769        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2770        let okx_quotes: Vec<OKXBookMsg> = match msg {
2771            OKXWsFrame::Data { data, .. } => serde_json::from_value(data).unwrap(),
2772            _ => panic!("Expected a `Data` variant"),
2773        };
2774        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2775
2776        let quote =
2777            parse_quote_msg(&okx_quotes[0], instrument_id, 2, 1, UnixNanos::default()).unwrap();
2778
2779        assert_eq!(quote.instrument_id, InstrumentId::from("BTC-USDT.OKX"));
2780        assert_eq!(quote.bid_price, Price::from("8476.97"));
2781        assert_eq!(quote.ask_price, Price::from("8476.98"));
2782        assert_eq!(quote.bid_size, Quantity::from(256));
2783        assert_eq!(quote.ask_size, Quantity::from(415));
2784        assert_eq!(quote.ts_event, UnixNanos::from(1597026383085000000));
2785        assert_eq!(quote.ts_init, UnixNanos::default());
2786    }
2787
2788    #[rstest]
2789    fn test_parse_trades() {
2790        let json_data = load_test_json("ws_trades.json");
2791        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2792        let okx_trades: Vec<OKXTradeMsg> = match msg {
2793            OKXWsFrame::Data { data, .. } => serde_json::from_value(data).unwrap(),
2794            _ => panic!("Expected a `Data` variant"),
2795        };
2796
2797        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2798        let trade =
2799            parse_trade_msg(&okx_trades[0], instrument_id, 1, 8, UnixNanos::default()).unwrap();
2800
2801        assert_eq!(trade.instrument_id, InstrumentId::from("BTC-USDT.OKX"));
2802        assert_eq!(trade.price, Price::from("42219.9"));
2803        assert_eq!(trade.size, Quantity::from("0.12060306"));
2804        assert_eq!(trade.aggressor_side, AggressorSide::Buy);
2805        assert_eq!(trade.trade_id, TradeId::from("130639474"));
2806        assert_eq!(trade.ts_event, UnixNanos::from(1630048897897000000));
2807        assert_eq!(trade.ts_init, UnixNanos::default());
2808    }
2809
2810    #[rstest]
2811    fn test_parse_candle() {
2812        let json_data = load_test_json("ws_candle.json");
2813        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2814        let okx_candles: Vec<OKXCandleMsg> = match msg {
2815            OKXWsFrame::Data { data, .. } => serde_json::from_value(data).unwrap(),
2816            _ => panic!("Expected a `Data` variant"),
2817        };
2818
2819        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2820        let bar_type = BarType::new(
2821            instrument_id,
2822            BAR_SPEC_1_DAY_LAST,
2823            AggregationSource::External,
2824        );
2825        let bar = parse_candle_msg(&okx_candles[0], bar_type, 2, 0, UnixNanos::default()).unwrap();
2826
2827        assert_eq!(bar.bar_type, bar_type);
2828        assert_eq!(bar.open, Price::from("8533.02"));
2829        assert_eq!(bar.high, Price::from("8553.74"));
2830        assert_eq!(bar.low, Price::from("8527.17"));
2831        assert_eq!(bar.close, Price::from("8548.26"));
2832        assert_eq!(bar.volume, Quantity::from(45247));
2833        assert_eq!(bar.ts_event, UnixNanos::from(1597026383085000000));
2834        assert_eq!(bar.ts_init, UnixNanos::default());
2835    }
2836
2837    #[rstest]
2838    fn test_parse_funding_rate() {
2839        let json_data = load_test_json("ws_funding_rate.json");
2840        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2841
2842        let okx_funding_rates: Vec<crate::websocket::messages::OKXFundingRateMsg> = match msg {
2843            OKXWsFrame::Data { data, .. } => serde_json::from_value(data).unwrap(),
2844            _ => panic!("Expected a `Data` variant"),
2845        };
2846
2847        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2848        let funding_rate =
2849            parse_funding_rate_msg(&okx_funding_rates[0], instrument_id, UnixNanos::default())
2850                .unwrap();
2851
2852        assert_eq!(funding_rate.instrument_id, instrument_id);
2853        assert_eq!(funding_rate.rate, dec!(0.0001));
2854        assert_eq!(funding_rate.interval, Some(8 * 60));
2855        assert_eq!(
2856            funding_rate.next_funding_ns,
2857            Some(UnixNanos::from(1744590349506000000))
2858        );
2859        assert_eq!(funding_rate.ts_event, UnixNanos::from(1744590349506000000));
2860        assert_eq!(funding_rate.ts_init, UnixNanos::default());
2861    }
2862
2863    #[rstest]
2864    fn test_parse_book_vec() {
2865        let json_data = load_test_json("ws_books_snapshot.json");
2866        let event: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2867        let (msgs, action): (Vec<OKXBookMsg>, OKXBookAction) = match event {
2868            OKXWsFrame::BookData { data, action, .. } => (data, action),
2869            _ => panic!("Expected BookData"),
2870        };
2871
2872        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2873        let deltas_vec =
2874            parse_book_msg_vec(msgs, &instrument_id, 8, 1, action, UnixNanos::default()).unwrap();
2875
2876        assert_eq!(deltas_vec.len(), 1);
2877
2878        if let Data::Deltas(d) = &deltas_vec[0] {
2879            assert_eq!(d.sequence, 123456);
2880        } else {
2881            panic!("Expected Deltas");
2882        }
2883    }
2884
2885    #[rstest]
2886    fn test_parse_ticker_vec() {
2887        let json_data = load_test_json("ws_tickers.json");
2888        let event: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2889        let data_val: serde_json::Value = match event {
2890            OKXWsFrame::Data { data, .. } => data,
2891            _ => panic!("Expected Data"),
2892        };
2893
2894        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2895        let quotes_vec =
2896            parse_ticker_msg_vec(data_val, &instrument_id, 8, 1, UnixNanos::default()).unwrap();
2897
2898        assert_eq!(quotes_vec.len(), 1);
2899
2900        if let Data::Quote(q) = &quotes_vec[0] {
2901            assert_eq!(q.bid_price, Price::from("8888.88000000"));
2902            assert_eq!(q.ask_price, Price::from("9999.99"));
2903        } else {
2904            panic!("Expected Quote");
2905        }
2906    }
2907
2908    #[rstest]
2909    fn test_parse_trade_vec() {
2910        let json_data = load_test_json("ws_trades.json");
2911        let event: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2912        let data_val: serde_json::Value = match event {
2913            OKXWsFrame::Data { data, .. } => data,
2914            _ => panic!("Expected Data"),
2915        };
2916
2917        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2918        let trades_vec =
2919            parse_trade_msg_vec(data_val, &instrument_id, 8, 1, UnixNanos::default()).unwrap();
2920
2921        assert_eq!(trades_vec.len(), 1);
2922
2923        if let Data::Trade(t) = &trades_vec[0] {
2924            assert_eq!(t.trade_id, TradeId::new("130639474"));
2925        } else {
2926            panic!("Expected Trade");
2927        }
2928    }
2929
2930    #[rstest]
2931    fn test_parse_candle_vec() {
2932        let json_data = load_test_json("ws_candle.json");
2933        let event: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2934        let data_val: serde_json::Value = match event {
2935            OKXWsFrame::Data { data, .. } => data,
2936            _ => panic!("Expected Data"),
2937        };
2938
2939        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2940        let bars_vec = parse_candle_msg_vec(
2941            data_val,
2942            &instrument_id,
2943            2,
2944            1,
2945            BAR_SPEC_1_DAY_LAST,
2946            UnixNanos::default(),
2947        )
2948        .unwrap();
2949
2950        assert_eq!(bars_vec.len(), 1);
2951
2952        if let Data::Bar(b) = &bars_vec[0] {
2953            assert_eq!(b.open, Price::from("8533.02"));
2954        } else {
2955            panic!("Expected Bar");
2956        }
2957    }
2958
2959    #[rstest]
2960    fn test_parse_book_message() {
2961        let json_data = load_test_json("ws_bbo_tbt.json");
2962        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
2963        let (okx_books, arg): (Vec<OKXBookMsg>, OKXWebSocketArg) = match msg {
2964            OKXWsFrame::Data { data, arg, .. } => (serde_json::from_value(data).unwrap(), arg),
2965            _ => panic!("Expected a `Data` variant"),
2966        };
2967
2968        assert_eq!(arg.channel, OKXWsChannel::BboTbt);
2969        assert_eq!(arg.inst_id.as_ref().unwrap(), &Ustr::from("BTC-USDT"));
2970        assert_eq!(arg.inst_type, None);
2971        assert_eq!(okx_books.len(), 1);
2972
2973        let book_msg = &okx_books[0];
2974
2975        // Check asks
2976        assert_eq!(book_msg.asks.len(), 1);
2977        let ask = &book_msg.asks[0];
2978        assert_eq!(ask.price, "8476.98");
2979        assert_eq!(ask.size, "415");
2980        assert_eq!(ask.liquidated_orders_count, "0");
2981        assert_eq!(ask.orders_count, "13");
2982
2983        // Check bids
2984        assert_eq!(book_msg.bids.len(), 1);
2985        let bid = &book_msg.bids[0];
2986        assert_eq!(bid.price, "8476.97");
2987        assert_eq!(bid.size, "256");
2988        assert_eq!(bid.liquidated_orders_count, "0");
2989        assert_eq!(bid.orders_count, "12");
2990        assert_eq!(book_msg.ts, 1597026383085);
2991        assert_eq!(book_msg.seq_id, 123456);
2992        assert_eq!(book_msg.checksum, None);
2993        assert_eq!(book_msg.prev_seq_id, None);
2994    }
2995
2996    #[rstest]
2997    fn test_parse_ws_account_message() {
2998        let json_data = load_test_json("ws_account.json");
2999        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
3000
3001        let OKXWsFrame::Data { data, .. } = msg else {
3002            panic!("Expected OKXWsFrame::Data");
3003        };
3004
3005        let accounts: Vec<OKXAccount> = serde_json::from_value(data).unwrap();
3006
3007        assert_eq!(accounts.len(), 1);
3008        let account = &accounts[0];
3009
3010        assert_eq!(account.total_eq, "100.56089404807182");
3011        assert_eq!(account.details.len(), 3);
3012
3013        let usdt_detail = &account.details[0];
3014        assert_eq!(usdt_detail.ccy, "USDT");
3015        assert_eq!(usdt_detail.avail_bal, "100.52768569797846");
3016        assert_eq!(usdt_detail.cash_bal, "100.52768569797846");
3017
3018        let btc_detail = &account.details[1];
3019        assert_eq!(btc_detail.ccy, "BTC");
3020        assert_eq!(btc_detail.avail_bal, "0.0000000051");
3021
3022        let eth_detail = &account.details[2];
3023        assert_eq!(eth_detail.ccy, "ETH");
3024        assert_eq!(eth_detail.avail_bal, "0.000000185");
3025
3026        let account_id = AccountId::new("OKX-001");
3027        let ts_init = UnixNanos::default();
3028        let account_state = parse_account_state(account, account_id, ts_init);
3029
3030        assert!(account_state.is_ok());
3031        let state = account_state.unwrap();
3032        assert_eq!(state.account_id, account_id);
3033        assert_eq!(state.balances.len(), 3);
3034    }
3035
3036    #[rstest]
3037    fn test_parse_ws_account_message_empty_balance() {
3038        // GH-3772: OKX returns empty strings and empty details for zero-balance accounts
3039        let json_data = load_test_json("ws_account_empty.json");
3040        let msg: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
3041
3042        let OKXWsFrame::Data { data, .. } = msg else {
3043            panic!("Expected OKXWsFrame::Data");
3044        };
3045
3046        let accounts: Vec<OKXAccount> = serde_json::from_value(data).unwrap();
3047        assert_eq!(accounts.len(), 1);
3048
3049        let account = &accounts[0];
3050        assert!(account.details.is_empty());
3051        assert_eq!(account.total_eq, "0");
3052
3053        let account_id = AccountId::new("OKX-001");
3054        let account_state = parse_account_state(account, account_id, UnixNanos::default()).unwrap();
3055
3056        assert_eq!(account_state.account_id, account_id);
3057        assert_eq!(account_state.margins.len(), 0);
3058        assert_eq!(account_state.balances.len(), 1);
3059
3060        let balance = &account_state.balances[0];
3061        assert_eq!(balance.total, Money::new(0.0, Currency::USD()));
3062        assert_eq!(balance.free, Money::new(0.0, Currency::USD()));
3063        assert_eq!(balance.locked, Money::new(0.0, Currency::USD()));
3064    }
3065
3066    #[rstest]
3067    fn test_parse_order_msg() {
3068        let json_data = load_test_json("ws_orders.json");
3069        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3070
3071        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3072
3073        let account_id = AccountId::new("OKX-001");
3074        let mut instruments = AHashMap::new();
3075
3076        // Create a mock instrument for testing
3077        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3078        let instrument = CryptoPerpetual::builder()
3079            .instrument_id(instrument_id)
3080            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3081            .base_currency(Currency::BTC())
3082            .quote_currency(Currency::USDT())
3083            .settlement_currency(Currency::USDT())
3084            .is_inverse(false)
3085            .price_precision(2)
3086            .size_precision(8)
3087            .price_increment(Price::from("0.01"))
3088            .size_increment(Quantity::from("0.00000001"))
3089            .ts_event(UnixNanos::default())
3090            .ts_init(UnixNanos::default())
3091            .build()
3092            .unwrap();
3093
3094        instruments.insert(
3095            Ustr::from("BTC-USDT-SWAP"),
3096            InstrumentAny::CryptoPerpetual(instrument),
3097        );
3098
3099        let ts_init = UnixNanos::default();
3100        let mut fee_cache = AHashMap::new();
3101        let mut filled_qty_cache = AHashMap::new();
3102
3103        let result = parse_order_msg_vec(
3104            &data,
3105            account_id,
3106            &instruments,
3107            &mut fee_cache,
3108            &mut filled_qty_cache,
3109            ts_init,
3110        );
3111
3112        assert!(result.is_ok());
3113        let order_reports = result.unwrap();
3114        assert_eq!(order_reports.len(), 1);
3115
3116        // Verify the parsed order report
3117        let report = &order_reports[0];
3118
3119        if let ExecutionReport::Fill(fill_report) = report {
3120            assert_eq!(fill_report.account_id, account_id);
3121            assert_eq!(fill_report.instrument_id, instrument_id);
3122            assert_eq!(
3123                fill_report.client_order_id,
3124                Some(ClientOrderId::new("001BTCUSDT20250106001"))
3125            );
3126            assert_eq!(
3127                fill_report.venue_order_id,
3128                VenueOrderId::new("2497956918703120384")
3129            );
3130            assert_eq!(fill_report.trade_id, TradeId::from("1518905529"));
3131            assert_eq!(fill_report.order_side, OrderSide::Buy);
3132            assert_eq!(fill_report.last_px, Price::from("103698.90"));
3133            assert_eq!(fill_report.last_qty, Quantity::from("0.03000000"));
3134            assert_eq!(fill_report.liquidity_side, LiquiditySide::Maker);
3135        } else {
3136            panic!("Expected Fill report for filled order");
3137        }
3138    }
3139
3140    #[rstest]
3141    fn test_parse_order_status_report() {
3142        let json_data = load_test_json("ws_orders.json");
3143        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3144        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3145        let order_msg = &data[0];
3146
3147        let account_id = AccountId::new("OKX-001");
3148        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3149        let instrument = CryptoPerpetual::builder()
3150            .instrument_id(instrument_id)
3151            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3152            .base_currency(Currency::BTC())
3153            .quote_currency(Currency::USDT())
3154            .settlement_currency(Currency::USDT())
3155            .is_inverse(false)
3156            .price_precision(2)
3157            .size_precision(8)
3158            .price_increment(Price::from("0.01"))
3159            .size_increment(Quantity::from("0.00000001"))
3160            .ts_event(UnixNanos::default())
3161            .ts_init(UnixNanos::default())
3162            .build()
3163            .unwrap();
3164
3165        let ts_init = UnixNanos::default();
3166
3167        let result = parse_order_status_report(
3168            order_msg,
3169            &InstrumentAny::CryptoPerpetual(instrument),
3170            account_id,
3171            ts_init,
3172        );
3173
3174        assert!(result.is_ok());
3175        let order_status_report = result.unwrap();
3176
3177        assert_eq!(order_status_report.account_id, account_id);
3178        assert_eq!(order_status_report.instrument_id, instrument_id);
3179        assert_eq!(
3180            order_status_report.client_order_id,
3181            Some(ClientOrderId::new("001BTCUSDT20250106001"))
3182        );
3183        assert_eq!(
3184            order_status_report.venue_order_id,
3185            VenueOrderId::new("2497956918703120384")
3186        );
3187        assert_eq!(order_status_report.order_side, OrderSide::Buy.into());
3188        assert_eq!(order_status_report.order_status, OrderStatus::Filled);
3189        assert_eq!(order_status_report.quantity, Quantity::from("0.03000000"));
3190        assert_eq!(order_status_report.filled_qty, Quantity::from("0.03000000"));
3191    }
3192
3193    #[rstest]
3194    fn test_parse_fill_report() {
3195        let json_data = load_test_json("ws_orders.json");
3196        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3197        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3198        let order_msg = &data[0];
3199
3200        let account_id = AccountId::new("OKX-001");
3201        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3202        let instrument = CryptoPerpetual::builder()
3203            .instrument_id(instrument_id)
3204            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3205            .base_currency(Currency::BTC())
3206            .quote_currency(Currency::USDT())
3207            .settlement_currency(Currency::USDT())
3208            .is_inverse(false)
3209            .price_precision(2)
3210            .size_precision(8)
3211            .price_increment(Price::from("0.01"))
3212            .size_increment(Quantity::from("0.00000001"))
3213            .ts_event(UnixNanos::default())
3214            .ts_init(UnixNanos::default())
3215            .build()
3216            .unwrap();
3217
3218        let ts_init = UnixNanos::default();
3219
3220        let result = parse_fill_report(
3221            order_msg,
3222            &InstrumentAny::CryptoPerpetual(instrument),
3223            account_id,
3224            None,
3225            None,
3226            ts_init,
3227        );
3228
3229        assert!(result.is_ok());
3230        let fill_report = result.unwrap().unwrap();
3231
3232        assert_eq!(fill_report.account_id, account_id);
3233        assert_eq!(fill_report.instrument_id, instrument_id);
3234        assert_eq!(
3235            fill_report.client_order_id,
3236            Some(ClientOrderId::new("001BTCUSDT20250106001"))
3237        );
3238        assert_eq!(
3239            fill_report.venue_order_id,
3240            VenueOrderId::new("2497956918703120384")
3241        );
3242        assert_eq!(fill_report.trade_id, TradeId::from("1518905529"));
3243        assert_eq!(fill_report.order_side, OrderSide::Buy);
3244        assert_eq!(fill_report.last_px, Price::from("103698.90"));
3245        assert_eq!(fill_report.last_qty, Quantity::from("0.03000000"));
3246        assert_eq!(fill_report.liquidity_side, LiquiditySide::Maker);
3247    }
3248
3249    #[rstest]
3250    fn test_parse_fill_report_rejects_missing_fee() {
3251        let instrument = create_stub_instrument();
3252        let mut msg = create_stub_order_msg("0.01", None, "ord-1", "trade-1");
3253        msg.fee = None;
3254
3255        let error = parse_fill_report(
3256            &msg,
3257            &InstrumentAny::CryptoPerpetual(instrument),
3258            AccountId::new("OKX-001"),
3259            None,
3260            None,
3261            UnixNanos::default(),
3262        )
3263        .unwrap_err();
3264
3265        assert!(error.to_string().contains("missing fee"));
3266    }
3267
3268    #[rstest]
3269    fn test_parse_book10_msg() {
3270        let json_data = load_test_json("ws_books_snapshot.json");
3271        let event: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
3272        let msgs: Vec<OKXBookMsg> = match event {
3273            OKXWsFrame::BookData { data, .. } => data,
3274            _ => panic!("Expected BookData"),
3275        };
3276
3277        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3278        let depth10 =
3279            parse_book10_msg(&msgs[0], instrument_id, 2, 0, UnixNanos::default()).unwrap();
3280
3281        assert_eq!(depth10.instrument_id, instrument_id);
3282        assert_eq!(depth10.sequence, 123456);
3283        assert_eq!(depth10.ts_event, UnixNanos::from(1597026383085000000));
3284        assert_eq!(depth10.flags, RecordFlag::F_SNAPSHOT as u8);
3285
3286        // Check bid levels (available in test data: 8 levels)
3287        assert_eq!(depth10.bids[0].price, Price::from("8476.97"));
3288        assert_eq!(depth10.bids[0].size, Quantity::from("256"));
3289        assert_eq!(depth10.bids[0].side, OrderSide::Buy.into());
3290        assert_eq!(depth10.bid_counts[0], 12);
3291
3292        assert_eq!(depth10.bids[1].price, Price::from("8475.55"));
3293        assert_eq!(depth10.bids[1].size, Quantity::from("101"));
3294        assert_eq!(depth10.bid_counts[1], 1);
3295
3296        // Check that levels beyond available data are padded with empty orders
3297        assert_eq!(depth10.bids[8].price, Price::from("0"));
3298        assert_eq!(depth10.bids[8].size, Quantity::from("0"));
3299        assert_eq!(depth10.bid_counts[8], 0);
3300
3301        // Check ask levels (available in test data: 8 levels)
3302        assert_eq!(depth10.asks[0].price, Price::from("8476.98"));
3303        assert_eq!(depth10.asks[0].size, Quantity::from("415"));
3304        assert_eq!(depth10.asks[0].side, OrderSide::Sell.into());
3305        assert_eq!(depth10.ask_counts[0], 13);
3306
3307        assert_eq!(depth10.asks[1].price, Price::from("8477.00"));
3308        assert_eq!(depth10.asks[1].size, Quantity::from("7"));
3309        assert_eq!(depth10.ask_counts[1], 2);
3310
3311        // Check that levels beyond available data are padded with empty orders
3312        assert_eq!(depth10.asks[8].price, Price::from("0"));
3313        assert_eq!(depth10.asks[8].size, Quantity::from("0"));
3314        assert_eq!(depth10.ask_counts[8], 0);
3315    }
3316
3317    #[rstest]
3318    fn test_parse_book10_msg_vec() {
3319        let json_data = load_test_json("ws_books_snapshot.json");
3320        let event: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
3321        let msgs: Vec<OKXBookMsg> = match event {
3322            OKXWsFrame::BookData { data, .. } => data,
3323            _ => panic!("Expected BookData"),
3324        };
3325
3326        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3327        let depth10_vec =
3328            parse_book10_msg_vec(msgs, &instrument_id, 2, 0, UnixNanos::default()).unwrap();
3329
3330        assert_eq!(depth10_vec.len(), 1);
3331
3332        if let Data::Depth10(d) = &depth10_vec[0] {
3333            assert_eq!(d.instrument_id, instrument_id);
3334            assert_eq!(d.sequence, 123456);
3335            assert_eq!(d.bids[0].price, Price::from("8476.97"));
3336            assert_eq!(d.asks[0].price, Price::from("8476.98"));
3337        } else {
3338            panic!("Expected Depth10");
3339        }
3340    }
3341
3342    #[rstest]
3343    fn test_parse_fill_report_with_fee_cache() {
3344        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3345        let instrument = CryptoPerpetual::builder()
3346            .instrument_id(instrument_id)
3347            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3348            .base_currency(Currency::BTC())
3349            .quote_currency(Currency::USDT())
3350            .settlement_currency(Currency::USDT())
3351            .is_inverse(false)
3352            .price_precision(2)
3353            .size_precision(8)
3354            .price_increment(Price::from("0.01"))
3355            .size_increment(Quantity::from("0.00000001"))
3356            .ts_event(UnixNanos::default())
3357            .ts_init(UnixNanos::default())
3358            .build()
3359            .unwrap();
3360
3361        let account_id = AccountId::new("OKX-001");
3362        let ts_init = UnixNanos::default();
3363
3364        // First fill: 0.01 BTC out of 0.03 BTC total (1/3)
3365        let order_msg_1 = OKXOrderMsg {
3366            acc_fill_sz: Some("0.01".to_string()),
3367            algo_id: None,
3368            avg_px: "50000.0".to_string(),
3369            c_time: 1746947317401,
3370            cancel_source: None,
3371            cancel_source_reason: None,
3372            category: OKXOrderCategory::Normal,
3373            ccy: Ustr::from("USDT"),
3374            cl_ord_id: "test_order_1".to_string(),
3375            algo_cl_ord_id: None,
3376            attach_algo_cl_ord_id: None,
3377            attach_algo_ords: Vec::new(),
3378            outcome: None,
3379            fee: Some("-1.0".to_string()), // Total fee so far
3380            fee_ccy: Ustr::from("USDT"),
3381            fill_fee: None,
3382            fill_fee_ccy: None,
3383            fill_mark_px: None,
3384            fill_mark_vol: None,
3385            fill_px_vol: None,
3386            fill_px_usd: None,
3387            fill_fwd_px: None,
3388            fill_notional_usd: None,
3389            fill_pnl: None,
3390            fill_px: "50000.0".to_string(),
3391            fill_sz: "0.01".to_string(),
3392            fill_time: 1746947317402,
3393            inst_id: Ustr::from("BTC-USDT-SWAP"),
3394            inst_type: OKXInstrumentType::Swap,
3395            is_tp_limit: None,
3396            lever: "2.0".to_string(),
3397            linked_algo_ord: None,
3398            notional_usd: None,
3399            ord_id: Ustr::from("1234567890"),
3400            ord_type: OKXOrderType::Market,
3401            pnl: "0".to_string(),
3402            pos_side: OKXPositionSide::Long,
3403            px: String::new(),
3404            px_type: OKXPriceType::None,
3405            px_usd: None,
3406            px_vol: None,
3407            quick_mgn_type: OKXQuickMarginType::None,
3408            rebate: None,
3409            rebate_ccy: None,
3410            reduce_only: "false".to_string(),
3411            side: OKXSide::Buy,
3412            sl_ord_px: None,
3413            sl_trigger_px: None,
3414            sl_trigger_px_type: None,
3415            source: None,
3416            state: OKXOrderStatus::PartiallyFilled,
3417            stp_id: None,
3418            stp_mode: OKXSelfTradePreventionMode::None,
3419            exec_type: OKXExecType::Maker,
3420            sz: "0.03".to_string(), // Total order size
3421            tag: None,
3422            td_mode: OKXTradeMode::Isolated,
3423            tgt_ccy: None,
3424            tp_ord_px: None,
3425            tp_trigger_px: None,
3426            tp_trigger_px_type: None,
3427            trade_id: "trade_1".to_string(),
3428            u_time: 1746947317402,
3429            amend_result: None,
3430            req_id: None,
3431            code: None,
3432            msg: None,
3433        };
3434
3435        let fill_report_1 = parse_fill_report(
3436            &order_msg_1,
3437            &InstrumentAny::CryptoPerpetual(instrument.clone()),
3438            account_id,
3439            None,
3440            None,
3441            ts_init,
3442        )
3443        .unwrap()
3444        .unwrap();
3445
3446        // First fill should get the full fee since there's no previous fee
3447        assert_eq!(fill_report_1.commission, Money::new(1.0, Currency::USDT()));
3448
3449        // Second fill: 0.02 BTC more, now 0.03 BTC total (completely filled)
3450        let order_msg_2 = OKXOrderMsg {
3451            acc_fill_sz: Some("0.03".to_string()),
3452            algo_id: None,
3453            avg_px: "50000.0".to_string(),
3454            c_time: 1746947317401,
3455            cancel_source: None,
3456            cancel_source_reason: None,
3457            category: OKXOrderCategory::Normal,
3458            ccy: Ustr::from("USDT"),
3459            cl_ord_id: "test_order_1".to_string(),
3460            algo_cl_ord_id: None,
3461            attach_algo_cl_ord_id: None,
3462            attach_algo_ords: Vec::new(),
3463            outcome: None,
3464            fee: Some("-3.0".to_string()), // Same total fee
3465            fee_ccy: Ustr::from("USDT"),
3466            fill_fee: None,
3467            fill_fee_ccy: None,
3468            fill_mark_px: None,
3469            fill_mark_vol: None,
3470            fill_px_vol: None,
3471            fill_px_usd: None,
3472            fill_fwd_px: None,
3473            fill_notional_usd: None,
3474            fill_pnl: None,
3475            fill_px: "50000.0".to_string(),
3476            fill_sz: "0.02".to_string(),
3477            fill_time: 1746947317403,
3478            inst_id: Ustr::from("BTC-USDT-SWAP"),
3479            inst_type: OKXInstrumentType::Swap,
3480            is_tp_limit: None,
3481            lever: "2.0".to_string(),
3482            linked_algo_ord: None,
3483            notional_usd: None,
3484            ord_id: Ustr::from("1234567890"),
3485            ord_type: OKXOrderType::Market,
3486            pnl: "0".to_string(),
3487            pos_side: OKXPositionSide::Long,
3488            px: String::new(),
3489            px_type: OKXPriceType::None,
3490            px_usd: None,
3491            px_vol: None,
3492            quick_mgn_type: OKXQuickMarginType::None,
3493            rebate: None,
3494            rebate_ccy: None,
3495            reduce_only: "false".to_string(),
3496            side: OKXSide::Buy,
3497            sl_ord_px: None,
3498            sl_trigger_px: None,
3499            sl_trigger_px_type: None,
3500            source: None,
3501            state: OKXOrderStatus::Filled,
3502            stp_id: None,
3503            stp_mode: OKXSelfTradePreventionMode::None,
3504            exec_type: OKXExecType::Maker,
3505            sz: "0.03".to_string(), // Same total order size
3506            tag: None,
3507            td_mode: OKXTradeMode::Isolated,
3508            tgt_ccy: None,
3509            tp_ord_px: None,
3510            tp_trigger_px: None,
3511            tp_trigger_px_type: None,
3512            trade_id: "trade_2".to_string(),
3513            u_time: 1746947317403,
3514            amend_result: None,
3515            req_id: None,
3516            code: None,
3517            msg: None,
3518        };
3519
3520        let fill_report_2 = parse_fill_report(
3521            &order_msg_2,
3522            &InstrumentAny::CryptoPerpetual(instrument),
3523            account_id,
3524            Some(fill_report_1.commission),
3525            Some(fill_report_1.last_qty),
3526            ts_init,
3527        )
3528        .unwrap()
3529        .unwrap();
3530
3531        // Second fill should get total_fee - previous_fee = 3.0 - 1.0 = 2.0
3532        assert_eq!(fill_report_2.commission, Money::new(2.0, Currency::USDT()));
3533
3534        // Test passed - fee was correctly split proportionally
3535    }
3536
3537    #[rstest]
3538    fn test_parse_fill_report_with_maker_rebates() {
3539        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3540        let instrument = CryptoPerpetual::builder()
3541            .instrument_id(instrument_id)
3542            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3543            .base_currency(Currency::BTC())
3544            .quote_currency(Currency::USDT())
3545            .settlement_currency(Currency::USDT())
3546            .is_inverse(false)
3547            .price_precision(2)
3548            .size_precision(8)
3549            .price_increment(Price::from("0.01"))
3550            .size_increment(Quantity::from("0.00000001"))
3551            .ts_event(UnixNanos::default())
3552            .ts_init(UnixNanos::default())
3553            .build()
3554            .unwrap();
3555
3556        let account_id = AccountId::new("OKX-001");
3557        let ts_init = UnixNanos::default();
3558
3559        // First fill: maker rebate of $0.5 (OKX sends as "0.5", parse_fee makes it -0.5)
3560        let order_msg_1 = OKXOrderMsg {
3561            acc_fill_sz: Some("0.01".to_string()),
3562            algo_id: None,
3563            avg_px: "50000.0".to_string(),
3564            c_time: 1746947317401,
3565            cancel_source: None,
3566            cancel_source_reason: None,
3567            category: OKXOrderCategory::Normal,
3568            ccy: Ustr::from("USDT"),
3569            cl_ord_id: "test_order_rebate".to_string(),
3570            algo_cl_ord_id: None,
3571            attach_algo_cl_ord_id: None,
3572            attach_algo_ords: Vec::new(),
3573            outcome: None,
3574            fee: Some("0.5".to_string()), // Rebate: positive value from OKX
3575            fee_ccy: Ustr::from("USDT"),
3576            fill_fee: None,
3577            fill_fee_ccy: None,
3578            fill_mark_px: None,
3579            fill_mark_vol: None,
3580            fill_px_vol: None,
3581            fill_px_usd: None,
3582            fill_fwd_px: None,
3583            fill_notional_usd: None,
3584            fill_pnl: None,
3585            fill_px: "50000.0".to_string(),
3586            fill_sz: "0.01".to_string(),
3587            fill_time: 1746947317402,
3588            inst_id: Ustr::from("BTC-USDT-SWAP"),
3589            inst_type: OKXInstrumentType::Swap,
3590            is_tp_limit: None,
3591            lever: "2.0".to_string(),
3592            linked_algo_ord: None,
3593            notional_usd: None,
3594            ord_id: Ustr::from("rebate_order_123"),
3595            ord_type: OKXOrderType::Market,
3596            pnl: "0".to_string(),
3597            pos_side: OKXPositionSide::Long,
3598            px: String::new(),
3599            px_type: OKXPriceType::None,
3600            px_usd: None,
3601            px_vol: None,
3602            quick_mgn_type: OKXQuickMarginType::None,
3603            rebate: None,
3604            rebate_ccy: None,
3605            reduce_only: "false".to_string(),
3606            side: OKXSide::Buy,
3607            sl_ord_px: None,
3608            sl_trigger_px: None,
3609            sl_trigger_px_type: None,
3610            source: None,
3611            state: OKXOrderStatus::PartiallyFilled,
3612            stp_id: None,
3613            stp_mode: OKXSelfTradePreventionMode::None,
3614            exec_type: OKXExecType::Maker,
3615            sz: "0.02".to_string(),
3616            tag: None,
3617            td_mode: OKXTradeMode::Isolated,
3618            tgt_ccy: None,
3619            tp_ord_px: None,
3620            tp_trigger_px: None,
3621            tp_trigger_px_type: None,
3622            trade_id: "trade_rebate_1".to_string(),
3623            u_time: 1746947317402,
3624            amend_result: None,
3625            req_id: None,
3626            code: None,
3627            msg: None,
3628        };
3629
3630        let fill_report_1 = parse_fill_report(
3631            &order_msg_1,
3632            &InstrumentAny::CryptoPerpetual(instrument.clone()),
3633            account_id,
3634            None,
3635            None,
3636            ts_init,
3637        )
3638        .unwrap()
3639        .unwrap();
3640
3641        // First fill gets the full rebate (negative commission)
3642        assert_eq!(fill_report_1.commission, Money::new(-0.5, Currency::USDT()));
3643
3644        // Second fill: another maker rebate of $0.3, cumulative now $0.8
3645        let order_msg_2 = OKXOrderMsg {
3646            acc_fill_sz: Some("0.02".to_string()),
3647            algo_id: None,
3648            avg_px: "50000.0".to_string(),
3649            c_time: 1746947317401,
3650            cancel_source: None,
3651            cancel_source_reason: None,
3652            category: OKXOrderCategory::Normal,
3653            ccy: Ustr::from("USDT"),
3654            cl_ord_id: "test_order_rebate".to_string(),
3655            algo_cl_ord_id: None,
3656            attach_algo_cl_ord_id: None,
3657            attach_algo_ords: Vec::new(),
3658            outcome: None,
3659            fee: Some("0.8".to_string()), // Cumulative rebate
3660            fee_ccy: Ustr::from("USDT"),
3661            fill_fee: None,
3662            fill_fee_ccy: None,
3663            fill_mark_px: None,
3664            fill_mark_vol: None,
3665            fill_px_vol: None,
3666            fill_px_usd: None,
3667            fill_fwd_px: None,
3668            fill_notional_usd: None,
3669            fill_pnl: None,
3670            fill_px: "50000.0".to_string(),
3671            fill_sz: "0.01".to_string(),
3672            fill_time: 1746947317403,
3673            inst_id: Ustr::from("BTC-USDT-SWAP"),
3674            inst_type: OKXInstrumentType::Swap,
3675            is_tp_limit: None,
3676            lever: "2.0".to_string(),
3677            linked_algo_ord: None,
3678            notional_usd: None,
3679            ord_id: Ustr::from("rebate_order_123"),
3680            ord_type: OKXOrderType::Market,
3681            pnl: "0".to_string(),
3682            pos_side: OKXPositionSide::Long,
3683            px: String::new(),
3684            px_type: OKXPriceType::None,
3685            px_usd: None,
3686            px_vol: None,
3687            quick_mgn_type: OKXQuickMarginType::None,
3688            rebate: None,
3689            rebate_ccy: None,
3690            reduce_only: "false".to_string(),
3691            side: OKXSide::Buy,
3692            sl_ord_px: None,
3693            sl_trigger_px: None,
3694            sl_trigger_px_type: None,
3695            source: None,
3696            state: OKXOrderStatus::Filled,
3697            stp_id: None,
3698            stp_mode: OKXSelfTradePreventionMode::None,
3699            exec_type: OKXExecType::Maker,
3700            sz: "0.02".to_string(),
3701            tag: None,
3702            td_mode: OKXTradeMode::Isolated,
3703            tgt_ccy: None,
3704            tp_ord_px: None,
3705            tp_trigger_px: None,
3706            tp_trigger_px_type: None,
3707            trade_id: "trade_rebate_2".to_string(),
3708            u_time: 1746947317403,
3709            amend_result: None,
3710            req_id: None,
3711            code: None,
3712            msg: None,
3713        };
3714
3715        let fill_report_2 = parse_fill_report(
3716            &order_msg_2,
3717            &InstrumentAny::CryptoPerpetual(instrument),
3718            account_id,
3719            Some(fill_report_1.commission),
3720            Some(fill_report_1.last_qty),
3721            ts_init,
3722        )
3723        .unwrap()
3724        .unwrap();
3725
3726        // Second fill: incremental = -0.8 - (-0.5) = -0.3
3727        assert_eq!(fill_report_2.commission, Money::new(-0.3, Currency::USDT()));
3728    }
3729
3730    #[rstest]
3731    fn test_parse_fill_report_rebate_to_charge_transition() {
3732        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3733        let instrument = CryptoPerpetual::builder()
3734            .instrument_id(instrument_id)
3735            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3736            .base_currency(Currency::BTC())
3737            .quote_currency(Currency::USDT())
3738            .settlement_currency(Currency::USDT())
3739            .is_inverse(false)
3740            .price_precision(2)
3741            .size_precision(8)
3742            .price_increment(Price::from("0.01"))
3743            .size_increment(Quantity::from("0.00000001"))
3744            .ts_event(UnixNanos::default())
3745            .ts_init(UnixNanos::default())
3746            .build()
3747            .unwrap();
3748
3749        let account_id = AccountId::new("OKX-001");
3750        let ts_init = UnixNanos::default();
3751
3752        // First fill: maker rebate of $1.0
3753        let order_msg_1 = OKXOrderMsg {
3754            acc_fill_sz: Some("0.01".to_string()),
3755            algo_id: None,
3756            avg_px: "50000.0".to_string(),
3757            c_time: 1746947317401,
3758            cancel_source: None,
3759            cancel_source_reason: None,
3760            category: OKXOrderCategory::Normal,
3761            ccy: Ustr::from("USDT"),
3762            cl_ord_id: "test_order_transition".to_string(),
3763            algo_cl_ord_id: None,
3764            attach_algo_cl_ord_id: None,
3765            attach_algo_ords: Vec::new(),
3766            outcome: None,
3767            fee: Some("1.0".to_string()), // Rebate from OKX
3768            fee_ccy: Ustr::from("USDT"),
3769            fill_fee: None,
3770            fill_fee_ccy: None,
3771            fill_mark_px: None,
3772            fill_mark_vol: None,
3773            fill_px_vol: None,
3774            fill_px_usd: None,
3775            fill_fwd_px: None,
3776            fill_notional_usd: None,
3777            fill_pnl: None,
3778            fill_px: "50000.0".to_string(),
3779            fill_sz: "0.01".to_string(),
3780            fill_time: 1746947317402,
3781            inst_id: Ustr::from("BTC-USDT-SWAP"),
3782            inst_type: OKXInstrumentType::Swap,
3783            is_tp_limit: None,
3784            lever: "2.0".to_string(),
3785            linked_algo_ord: None,
3786            notional_usd: None,
3787            ord_id: Ustr::from("transition_order_456"),
3788            ord_type: OKXOrderType::Market,
3789            pnl: "0".to_string(),
3790            pos_side: OKXPositionSide::Long,
3791            px: String::new(),
3792            px_type: OKXPriceType::None,
3793            px_usd: None,
3794            px_vol: None,
3795            quick_mgn_type: OKXQuickMarginType::None,
3796            rebate: None,
3797            rebate_ccy: None,
3798            reduce_only: "false".to_string(),
3799            side: OKXSide::Buy,
3800            sl_ord_px: None,
3801            sl_trigger_px: None,
3802            sl_trigger_px_type: None,
3803            source: None,
3804            state: OKXOrderStatus::PartiallyFilled,
3805            stp_id: None,
3806            stp_mode: OKXSelfTradePreventionMode::None,
3807            exec_type: OKXExecType::Maker,
3808            sz: "0.02".to_string(),
3809            tag: None,
3810            td_mode: OKXTradeMode::Isolated,
3811            tgt_ccy: None,
3812            tp_ord_px: None,
3813            tp_trigger_px: None,
3814            tp_trigger_px_type: None,
3815            trade_id: "trade_transition_1".to_string(),
3816            u_time: 1746947317402,
3817            amend_result: None,
3818            req_id: None,
3819            code: None,
3820            msg: None,
3821        };
3822
3823        let fill_report_1 = parse_fill_report(
3824            &order_msg_1,
3825            &InstrumentAny::CryptoPerpetual(instrument.clone()),
3826            account_id,
3827            None,
3828            None,
3829            ts_init,
3830        )
3831        .unwrap()
3832        .unwrap();
3833
3834        // First fill gets rebate (negative)
3835        assert_eq!(fill_report_1.commission, Money::new(-1.0, Currency::USDT()));
3836
3837        // Second fill: taker charge of $5.0, net cumulative is now $2.0 charge
3838        // This is the edge case: incremental = 2.0 - (-1.0) = 3.0, which exceeds total (2.0)
3839        // But it's legitimate, not corruption
3840        let order_msg_2 = OKXOrderMsg {
3841            acc_fill_sz: Some("0.02".to_string()),
3842            algo_id: None,
3843            avg_px: "50000.0".to_string(),
3844            c_time: 1746947317401,
3845            cancel_source: None,
3846            cancel_source_reason: None,
3847            category: OKXOrderCategory::Normal,
3848            ccy: Ustr::from("USDT"),
3849            cl_ord_id: "test_order_transition".to_string(),
3850            algo_cl_ord_id: None,
3851            attach_algo_cl_ord_id: None,
3852            attach_algo_ords: Vec::new(),
3853            outcome: None,
3854            fee: Some("-2.0".to_string()), // Now a charge (negative from OKX)
3855            fee_ccy: Ustr::from("USDT"),
3856            fill_fee: None,
3857            fill_fee_ccy: None,
3858            fill_mark_px: None,
3859            fill_mark_vol: None,
3860            fill_px_vol: None,
3861            fill_px_usd: None,
3862            fill_fwd_px: None,
3863            fill_notional_usd: None,
3864            fill_pnl: None,
3865            fill_px: "50000.0".to_string(),
3866            fill_sz: "0.01".to_string(),
3867            fill_time: 1746947317403,
3868            inst_id: Ustr::from("BTC-USDT-SWAP"),
3869            inst_type: OKXInstrumentType::Swap,
3870            is_tp_limit: None,
3871            lever: "2.0".to_string(),
3872            linked_algo_ord: None,
3873            notional_usd: None,
3874            ord_id: Ustr::from("transition_order_456"),
3875            ord_type: OKXOrderType::Market,
3876            pnl: "0".to_string(),
3877            pos_side: OKXPositionSide::Long,
3878            px: String::new(),
3879            px_type: OKXPriceType::None,
3880            px_usd: None,
3881            px_vol: None,
3882            quick_mgn_type: OKXQuickMarginType::None,
3883            rebate: None,
3884            rebate_ccy: None,
3885            reduce_only: "false".to_string(),
3886            side: OKXSide::Buy,
3887            sl_ord_px: None,
3888            sl_trigger_px: None,
3889            sl_trigger_px_type: None,
3890            source: None,
3891            state: OKXOrderStatus::Filled,
3892            stp_id: None,
3893            stp_mode: OKXSelfTradePreventionMode::None,
3894            exec_type: OKXExecType::Taker,
3895            sz: "0.02".to_string(),
3896            tag: None,
3897            td_mode: OKXTradeMode::Isolated,
3898            tgt_ccy: None,
3899            tp_ord_px: None,
3900            tp_trigger_px: None,
3901            tp_trigger_px_type: None,
3902            trade_id: "trade_transition_2".to_string(),
3903            u_time: 1746947317403,
3904            amend_result: None,
3905            req_id: None,
3906            code: None,
3907            msg: None,
3908        };
3909
3910        let fill_report_2 = parse_fill_report(
3911            &order_msg_2,
3912            &InstrumentAny::CryptoPerpetual(instrument),
3913            account_id,
3914            Some(fill_report_1.commission),
3915            Some(fill_report_1.last_qty),
3916            ts_init,
3917        )
3918        .unwrap()
3919        .unwrap();
3920
3921        // Second fill: incremental = 2.0 - (-1.0) = 3.0
3922        // This should NOT trigger corruption detection because previous was negative
3923        assert_eq!(fill_report_2.commission, Money::new(3.0, Currency::USDT()));
3924    }
3925
3926    #[rstest]
3927    fn test_parse_fill_report_negative_incremental() {
3928        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3929        let instrument = CryptoPerpetual::builder()
3930            .instrument_id(instrument_id)
3931            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
3932            .base_currency(Currency::BTC())
3933            .quote_currency(Currency::USDT())
3934            .settlement_currency(Currency::USDT())
3935            .is_inverse(false)
3936            .price_precision(2)
3937            .size_precision(8)
3938            .price_increment(Price::from("0.01"))
3939            .size_increment(Quantity::from("0.00000001"))
3940            .ts_event(UnixNanos::default())
3941            .ts_init(UnixNanos::default())
3942            .build()
3943            .unwrap();
3944
3945        let account_id = AccountId::new("OKX-001");
3946        let ts_init = UnixNanos::default();
3947
3948        // First fill: charge of $2.0
3949        let order_msg_1 = OKXOrderMsg {
3950            acc_fill_sz: Some("0.01".to_string()),
3951            algo_id: None,
3952            avg_px: "50000.0".to_string(),
3953            c_time: 1746947317401,
3954            cancel_source: None,
3955            cancel_source_reason: None,
3956            category: OKXOrderCategory::Normal,
3957            ccy: Ustr::from("USDT"),
3958            cl_ord_id: "test_order_neg_inc".to_string(),
3959            algo_cl_ord_id: None,
3960            attach_algo_cl_ord_id: None,
3961            attach_algo_ords: Vec::new(),
3962            outcome: None,
3963            fee: Some("-2.0".to_string()),
3964            fee_ccy: Ustr::from("USDT"),
3965            fill_fee: None,
3966            fill_fee_ccy: None,
3967            fill_mark_px: None,
3968            fill_mark_vol: None,
3969            fill_px_vol: None,
3970            fill_px_usd: None,
3971            fill_fwd_px: None,
3972            fill_notional_usd: None,
3973            fill_pnl: None,
3974            fill_px: "50000.0".to_string(),
3975            fill_sz: "0.01".to_string(),
3976            fill_time: 1746947317402,
3977            inst_id: Ustr::from("BTC-USDT-SWAP"),
3978            inst_type: OKXInstrumentType::Swap,
3979            is_tp_limit: None,
3980            lever: "2.0".to_string(),
3981            linked_algo_ord: None,
3982            notional_usd: None,
3983            ord_id: Ustr::from("neg_inc_order_789"),
3984            ord_type: OKXOrderType::Market,
3985            pnl: "0".to_string(),
3986            pos_side: OKXPositionSide::Long,
3987            px: String::new(),
3988            px_type: OKXPriceType::None,
3989            px_usd: None,
3990            px_vol: None,
3991            quick_mgn_type: OKXQuickMarginType::None,
3992            rebate: None,
3993            rebate_ccy: None,
3994            reduce_only: "false".to_string(),
3995            side: OKXSide::Buy,
3996            sl_ord_px: None,
3997            sl_trigger_px: None,
3998            sl_trigger_px_type: None,
3999            source: None,
4000            state: OKXOrderStatus::PartiallyFilled,
4001            stp_id: None,
4002            stp_mode: OKXSelfTradePreventionMode::None,
4003            exec_type: OKXExecType::Taker,
4004            sz: "0.02".to_string(),
4005            tag: None,
4006            td_mode: OKXTradeMode::Isolated,
4007            tgt_ccy: None,
4008            tp_ord_px: None,
4009            tp_trigger_px: None,
4010            tp_trigger_px_type: None,
4011            trade_id: "trade_neg_inc_1".to_string(),
4012            u_time: 1746947317402,
4013            amend_result: None,
4014            req_id: None,
4015            code: None,
4016            msg: None,
4017        };
4018
4019        let fill_report_1 = parse_fill_report(
4020            &order_msg_1,
4021            &InstrumentAny::CryptoPerpetual(instrument.clone()),
4022            account_id,
4023            None,
4024            None,
4025            ts_init,
4026        )
4027        .unwrap()
4028        .unwrap();
4029
4030        assert_eq!(fill_report_1.commission, Money::new(2.0, Currency::USDT()));
4031
4032        // Second fill: charge reduced to $1.5 total (refund or maker rebate on this fill)
4033        // Incremental = 1.5 - 2.0 = -0.5 (negative incremental triggers debug log)
4034        let order_msg_2 = OKXOrderMsg {
4035            acc_fill_sz: Some("0.02".to_string()),
4036            algo_id: None,
4037            avg_px: "50000.0".to_string(),
4038            c_time: 1746947317401,
4039            cancel_source: None,
4040            cancel_source_reason: None,
4041            category: OKXOrderCategory::Normal,
4042            ccy: Ustr::from("USDT"),
4043            cl_ord_id: "test_order_neg_inc".to_string(),
4044            algo_cl_ord_id: None,
4045            attach_algo_cl_ord_id: None,
4046            attach_algo_ords: Vec::new(),
4047            outcome: None,
4048            fee: Some("-1.5".to_string()), // Total reduced
4049            fee_ccy: Ustr::from("USDT"),
4050            fill_fee: None,
4051            fill_fee_ccy: None,
4052            fill_mark_px: None,
4053            fill_mark_vol: None,
4054            fill_px_vol: None,
4055            fill_px_usd: None,
4056            fill_fwd_px: None,
4057            fill_notional_usd: None,
4058            fill_pnl: None,
4059            fill_px: "50000.0".to_string(),
4060            fill_sz: "0.01".to_string(),
4061            fill_time: 1746947317403,
4062            inst_id: Ustr::from("BTC-USDT-SWAP"),
4063            inst_type: OKXInstrumentType::Swap,
4064            is_tp_limit: None,
4065            lever: "2.0".to_string(),
4066            linked_algo_ord: None,
4067            notional_usd: None,
4068            ord_id: Ustr::from("neg_inc_order_789"),
4069            ord_type: OKXOrderType::Market,
4070            pnl: "0".to_string(),
4071            pos_side: OKXPositionSide::Long,
4072            px: String::new(),
4073            px_type: OKXPriceType::None,
4074            px_usd: None,
4075            px_vol: None,
4076            quick_mgn_type: OKXQuickMarginType::None,
4077            rebate: None,
4078            rebate_ccy: None,
4079            reduce_only: "false".to_string(),
4080            side: OKXSide::Buy,
4081            sl_ord_px: None,
4082            sl_trigger_px: None,
4083            sl_trigger_px_type: None,
4084            source: None,
4085            state: OKXOrderStatus::Filled,
4086            stp_id: None,
4087            stp_mode: OKXSelfTradePreventionMode::None,
4088            exec_type: OKXExecType::Maker,
4089            sz: "0.02".to_string(),
4090            tag: None,
4091            td_mode: OKXTradeMode::Isolated,
4092            tgt_ccy: None,
4093            tp_ord_px: None,
4094            tp_trigger_px: None,
4095            tp_trigger_px_type: None,
4096            trade_id: "trade_neg_inc_2".to_string(),
4097            u_time: 1746947317403,
4098            amend_result: None,
4099            req_id: None,
4100            code: None,
4101            msg: None,
4102        };
4103
4104        let fill_report_2 = parse_fill_report(
4105            &order_msg_2,
4106            &InstrumentAny::CryptoPerpetual(instrument),
4107            account_id,
4108            Some(fill_report_1.commission),
4109            Some(fill_report_1.last_qty),
4110            ts_init,
4111        )
4112        .unwrap()
4113        .unwrap();
4114
4115        // Incremental is negative: 1.5 - 2.0 = -0.5
4116        assert_eq!(fill_report_2.commission, Money::new(-0.5, Currency::USDT()));
4117    }
4118
4119    #[rstest]
4120    fn test_parse_fill_report_fee_currency_change_no_panic() {
4121        let instrument = create_stub_instrument();
4122        let account_id = AccountId::new("OKX-001");
4123        let ts_init = UnixNanos::default();
4124
4125        // First fill charged in USDT
4126        let previous_fee = Money::new(1.0, Currency::USDT());
4127
4128        // Second fill charged in BTC (fee currency changed)
4129        let mut order_msg =
4130            create_stub_order_msg("0.01", Some("0.02".to_string()), "1234567890", "trade_2");
4131        order_msg.fee = Some("-0.00005".to_string());
4132        order_msg.fee_ccy = Ustr::from("BTC");
4133
4134        let result = parse_fill_report(
4135            &order_msg,
4136            &InstrumentAny::CryptoPerpetual(instrument),
4137            account_id,
4138            Some(previous_fee),
4139            Some(Quantity::from("0.01")),
4140            ts_init,
4141        );
4142
4143        let fill_report = result.unwrap().unwrap();
4144        assert_eq!(fill_report.commission.currency, Currency::BTC());
4145    }
4146
4147    #[rstest]
4148    fn test_parse_fill_report_empty_fill_sz_first_fill() {
4149        let instrument = create_stub_instrument();
4150        let account_id = AccountId::new("OKX-001");
4151        let ts_init = UnixNanos::default();
4152
4153        let order_msg =
4154            create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "trade_1");
4155
4156        let fill_report = parse_fill_report(
4157            &order_msg,
4158            &InstrumentAny::CryptoPerpetual(instrument),
4159            account_id,
4160            None,
4161            None,
4162            ts_init,
4163        )
4164        .unwrap()
4165        .unwrap();
4166
4167        assert_eq!(fill_report.last_qty, Quantity::from("0.01"));
4168    }
4169
4170    #[rstest]
4171    fn test_parse_fill_report_empty_fill_sz_subsequent_fills() {
4172        let instrument = create_stub_instrument();
4173        let account_id = AccountId::new("OKX-001");
4174        let ts_init = UnixNanos::default();
4175
4176        let order_msg_1 =
4177            create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "trade_1");
4178
4179        let fill_report_1 = parse_fill_report(
4180            &order_msg_1,
4181            &InstrumentAny::CryptoPerpetual(instrument.clone()),
4182            account_id,
4183            None,
4184            None,
4185            ts_init,
4186        )
4187        .unwrap()
4188        .unwrap();
4189
4190        assert_eq!(fill_report_1.last_qty, Quantity::from("0.01"));
4191
4192        let order_msg_2 =
4193            create_stub_order_msg("", Some("0.03".to_string()), "1234567890", "trade_2");
4194
4195        let fill_report_2 = parse_fill_report(
4196            &order_msg_2,
4197            &InstrumentAny::CryptoPerpetual(instrument),
4198            account_id,
4199            Some(fill_report_1.commission),
4200            Some(fill_report_1.last_qty),
4201            ts_init,
4202        )
4203        .unwrap()
4204        .unwrap();
4205
4206        assert_eq!(fill_report_2.last_qty, Quantity::from("0.02"));
4207    }
4208
4209    #[rstest]
4210    fn test_parse_fill_report_error_both_empty() {
4211        let instrument = create_stub_instrument();
4212        let account_id = AccountId::new("OKX-001");
4213        let ts_init = UnixNanos::default();
4214
4215        let order_msg = create_stub_order_msg("", Some(String::new()), "1234567890", "trade_1");
4216
4217        let result = parse_fill_report(
4218            &order_msg,
4219            &InstrumentAny::CryptoPerpetual(instrument),
4220            account_id,
4221            None,
4222            None,
4223            ts_init,
4224        );
4225
4226        assert!(result.is_err());
4227        let err_msg = result.unwrap_err().to_string();
4228        assert!(err_msg.contains("Cannot determine fill quantity"));
4229        assert!(err_msg.contains("empty/zero"));
4230    }
4231
4232    #[rstest]
4233    fn test_parse_fill_report_error_acc_fill_sz_none() {
4234        let instrument = create_stub_instrument();
4235        let account_id = AccountId::new("OKX-001");
4236        let ts_init = UnixNanos::default();
4237
4238        let order_msg = create_stub_order_msg("", None, "1234567890", "trade_1");
4239
4240        let result = parse_fill_report(
4241            &order_msg,
4242            &InstrumentAny::CryptoPerpetual(instrument),
4243            account_id,
4244            None,
4245            None,
4246            ts_init,
4247        );
4248
4249        assert!(result.is_err());
4250        let err_msg = result.unwrap_err().to_string();
4251        assert!(err_msg.contains("Cannot determine fill quantity"));
4252        assert!(err_msg.contains("acc_fill_sz is None"));
4253    }
4254
4255    #[rstest]
4256    fn test_parse_fill_report_error_acc_fill_sz_less_than_previous() {
4257        let instrument = create_stub_instrument();
4258        let account_id = AccountId::new("OKX-001");
4259        let ts_init = UnixNanos::default();
4260
4261        // acc_fill_sz (0.01) < previous_filled_qty (0.03) - stale data after reconnect
4262        let order_msg =
4263            create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "trade_2");
4264
4265        let result = parse_fill_report(
4266            &order_msg,
4267            &InstrumentAny::CryptoPerpetual(instrument),
4268            account_id,
4269            None,
4270            Some(Quantity::from("0.03")),
4271            ts_init,
4272        );
4273
4274        assert!(result.is_err());
4275        let err_msg = result.unwrap_err().to_string();
4276        assert!(err_msg.contains("Cumulative fill went backwards"));
4277    }
4278
4279    #[rstest]
4280    fn test_parse_order_msg_acc_fill_sz_only_update() {
4281        // Test that we emit fill reports when OKX only updates acc_fill_sz without fill_sz or trade_id
4282        let instrument = create_stub_instrument();
4283        let account_id = AccountId::new("OKX-001");
4284        let ts_init = UnixNanos::default();
4285
4286        let mut instruments = AHashMap::new();
4287        instruments.insert(
4288            Ustr::from("BTC-USDT-SWAP"),
4289            InstrumentAny::CryptoPerpetual(instrument),
4290        );
4291
4292        let fee_cache = AHashMap::new();
4293        let mut filled_qty_cache = AHashMap::new();
4294
4295        // First update: acc_fill_sz = 0.01, no fill_sz, no trade_id
4296        let msg_1 = create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "");
4297
4298        let report_1 = parse_order_msg(
4299            &msg_1,
4300            account_id,
4301            &instruments,
4302            &fee_cache,
4303            &filled_qty_cache,
4304            ts_init,
4305        )
4306        .unwrap();
4307
4308        // Should generate a fill report (not a status report)
4309        assert!(matches!(report_1, ExecutionReport::Fill(_)));
4310        if let ExecutionReport::Fill(fill) = &report_1 {
4311            assert_eq!(fill.last_qty, Quantity::from("0.01"));
4312        }
4313
4314        // Update cache
4315        filled_qty_cache.insert(Ustr::from("1234567890"), Quantity::from("0.01"));
4316
4317        // Second update: acc_fill_sz increased to 0.03, still no fill_sz or trade_id
4318        let msg_2 = create_stub_order_msg("", Some("0.03".to_string()), "1234567890", "");
4319
4320        let report_2 = parse_order_msg(
4321            &msg_2,
4322            account_id,
4323            &instruments,
4324            &fee_cache,
4325            &filled_qty_cache,
4326            ts_init,
4327        )
4328        .unwrap();
4329
4330        // Should still generate a fill report for the incremental 0.02
4331        assert!(matches!(report_2, ExecutionReport::Fill(_)));
4332        if let ExecutionReport::Fill(fill) = &report_2 {
4333            assert_eq!(fill.last_qty, Quantity::from("0.02"));
4334        }
4335    }
4336
4337    #[rstest]
4338    fn test_parse_book10_msg_partial_levels() {
4339        // Test with fewer than 10 levels - should pad with empty orders
4340        let book_msg = OKXBookMsg {
4341            asks: vec![
4342                OrderBookEntry {
4343                    price: "8476.98".to_string(),
4344                    size: "415".to_string(),
4345                    liquidated_orders_count: "0".to_string(),
4346                    orders_count: "13".to_string(),
4347                },
4348                OrderBookEntry {
4349                    price: "8477.00".to_string(),
4350                    size: "7".to_string(),
4351                    liquidated_orders_count: "0".to_string(),
4352                    orders_count: "2".to_string(),
4353                },
4354            ],
4355            bids: vec![OrderBookEntry {
4356                price: "8476.97".to_string(),
4357                size: "256".to_string(),
4358                liquidated_orders_count: "0".to_string(),
4359                orders_count: "12".to_string(),
4360            }],
4361            ts: 1597026383085,
4362            checksum: None,
4363            prev_seq_id: None,
4364            seq_id: 123456,
4365        };
4366
4367        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
4368        let depth10 =
4369            parse_book10_msg(&book_msg, instrument_id, 2, 0, UnixNanos::default()).unwrap();
4370
4371        // Check that first levels have data
4372        assert_eq!(depth10.bids[0].price, Price::from("8476.97"));
4373        assert_eq!(depth10.bids[0].size, Quantity::from("256"));
4374        assert_eq!(depth10.bid_counts[0], 12);
4375
4376        // Check that remaining levels are padded with default (empty) orders
4377        assert_eq!(depth10.bids[1].price, Price::from("0"));
4378        assert_eq!(depth10.bids[1].size, Quantity::from("0"));
4379        assert_eq!(depth10.bid_counts[1], 0);
4380
4381        // Check asks
4382        assert_eq!(depth10.asks[0].price, Price::from("8476.98"));
4383        assert_eq!(depth10.asks[1].price, Price::from("8477.00"));
4384        assert_eq!(depth10.asks[2].price, Price::from("0")); // padded with empty
4385    }
4386
4387    #[rstest]
4388    fn test_parse_algo_order_msg_stop_market() {
4389        let json_data = load_test_json("ws_orders_algo.json");
4390        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4391        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4392
4393        // Test first algo order (stop market sell)
4394        let msg = &data[0];
4395        assert_eq!(msg.algo_id, "706620792746729472");
4396        assert_eq!(msg.algo_cl_ord_id, "STOP001BTCUSDT20250120");
4397        assert_eq!(msg.state, OKXAlgoOrderStatus::Live);
4398        assert_eq!(msg.ord_px, "-1"); // Market order indicator
4399
4400        let account_id = AccountId::new("OKX-001");
4401        let mut instruments = AHashMap::new();
4402
4403        // Create mock instrument
4404        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4405        let instrument = CryptoPerpetual::builder()
4406            .instrument_id(instrument_id)
4407            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
4408            .base_currency(Currency::BTC())
4409            .quote_currency(Currency::USDT())
4410            .settlement_currency(Currency::USDT())
4411            .is_inverse(false)
4412            .price_precision(2)
4413            .size_precision(8)
4414            .price_increment(Price::from("0.01"))
4415            .size_increment(Quantity::from("0.00000001"))
4416            .ts_event(0.into())
4417            .ts_init(0.into())
4418            .build()
4419            .unwrap();
4420        instruments.insert(
4421            Ustr::from("BTC-USDT-SWAP"),
4422            InstrumentAny::CryptoPerpetual(instrument),
4423        );
4424
4425        let result = parse_algo_order_msg(msg, account_id, &instruments, UnixNanos::default());
4426
4427        let report = result.unwrap().unwrap();
4428
4429        if let ExecutionReport::Order(status_report) = report {
4430            assert_eq!(status_report.order_type, OrderType::StopMarket);
4431            assert_eq!(status_report.order_side, OrderSide::Sell.into());
4432            assert_eq!(status_report.quantity, Quantity::from("0.01000000"));
4433            assert_eq!(status_report.trigger_price, Some(Price::from("95000.00")));
4434            assert_eq!(status_report.trigger_type, Some(TriggerType::LastPrice));
4435            assert_eq!(status_report.price, None); // No limit price for market orders
4436        } else {
4437            panic!("Expected Order report");
4438        }
4439    }
4440
4441    #[rstest]
4442    fn test_parse_algo_order_msg_stop_limit() {
4443        let json_data = load_test_json("ws_orders_algo.json");
4444        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4445        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4446
4447        // Test second algo order (stop limit buy)
4448        let msg = &data[1];
4449        assert_eq!(msg.algo_id, "706620792746729473");
4450        assert_eq!(msg.state, OKXAlgoOrderStatus::Live);
4451        assert_eq!(msg.ord_px, "106000"); // Limit price
4452
4453        let account_id = AccountId::new("OKX-001");
4454        let mut instruments = AHashMap::new();
4455
4456        // Create mock instrument
4457        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4458        let instrument = CryptoPerpetual::builder()
4459            .instrument_id(instrument_id)
4460            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
4461            .base_currency(Currency::BTC())
4462            .quote_currency(Currency::USDT())
4463            .settlement_currency(Currency::USDT())
4464            .is_inverse(false)
4465            .price_precision(2)
4466            .size_precision(8)
4467            .price_increment(Price::from("0.01"))
4468            .size_increment(Quantity::from("0.00000001"))
4469            .ts_event(0.into())
4470            .ts_init(0.into())
4471            .build()
4472            .unwrap();
4473        instruments.insert(
4474            Ustr::from("BTC-USDT-SWAP"),
4475            InstrumentAny::CryptoPerpetual(instrument),
4476        );
4477
4478        let result = parse_algo_order_msg(msg, account_id, &instruments, UnixNanos::default());
4479
4480        let report = result.unwrap().unwrap();
4481
4482        if let ExecutionReport::Order(status_report) = report {
4483            assert_eq!(status_report.order_type, OrderType::StopLimit);
4484            assert_eq!(status_report.order_side, OrderSide::Buy.into());
4485            assert_eq!(status_report.quantity, Quantity::from("0.02000000"));
4486            assert_eq!(status_report.trigger_price, Some(Price::from("105000.00")));
4487            assert_eq!(status_report.trigger_type, Some(TriggerType::MarkPrice));
4488            assert_eq!(status_report.price, Some(Price::from("106000.00"))); // Has limit price
4489        } else {
4490            panic!("Expected Order report");
4491        }
4492    }
4493
4494    #[rstest]
4495    fn test_parse_triggered_algo_order_preserves_parent_identity() {
4496        let json_data = load_test_json("ws_orders_algo.json");
4497        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4498        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4499        let msg = &data[2];
4500
4501        assert_eq!(msg.state, OKXAlgoOrderStatus::OrderPlaced);
4502        assert_eq!(msg.algo_cl_ord_id, "STOP003BTCUSDT20250120");
4503        assert_eq!(msg.cl_ord_id, "706620792746729474_0");
4504        assert_eq!(msg.actual_sz, "0.01");
4505
4506        let account_id = AccountId::new("OKX-001");
4507        let instrument = create_stub_instrument();
4508        let mut instruments = AHashMap::new();
4509        instruments.insert(
4510            Ustr::from("BTC-USDT-SWAP"),
4511            InstrumentAny::CryptoPerpetual(instrument),
4512        );
4513
4514        let report = parse_algo_order_msg(msg, account_id, &instruments, UnixNanos::default())
4515            .unwrap()
4516            .unwrap();
4517        let ExecutionReport::Order(report) = report else {
4518            panic!("Expected Order report");
4519        };
4520
4521        assert_eq!(
4522            report.client_order_id,
4523            Some(ClientOrderId::from("STOP003BTCUSDT20250120"))
4524        );
4525        assert_eq!(
4526            report.venue_order_id,
4527            VenueOrderId::from("706620792746729999")
4528        );
4529        assert_eq!(report.order_status, OrderStatus::Triggered);
4530        assert_eq!(report.filled_qty, Quantity::from("0.00000000"));
4531    }
4532
4533    #[rstest]
4534    fn test_parse_filled_algo_order_uses_actual_quantity() {
4535        let json_data = load_test_json("ws_orders_algo.json");
4536        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4537        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4538        let msg = &data[4];
4539
4540        assert_eq!(msg.state, OKXAlgoOrderStatus::Filled);
4541        assert_eq!(msg.actual_sz, "0.005");
4542
4543        let instrument = create_stub_instrument();
4544        let mut instruments = AHashMap::new();
4545        instruments.insert(
4546            Ustr::from("BTC-USDT-SWAP"),
4547            InstrumentAny::CryptoPerpetual(instrument),
4548        );
4549
4550        let report = parse_algo_order_msg(
4551            msg,
4552            AccountId::new("OKX-001"),
4553            &instruments,
4554            UnixNanos::default(),
4555        )
4556        .unwrap()
4557        .unwrap();
4558        let ExecutionReport::Order(report) = report else {
4559            panic!("Expected Order report");
4560        };
4561
4562        assert_eq!(report.order_status, OrderStatus::Filled);
4563        assert_eq!(report.filled_qty, Quantity::from("0.00500000"));
4564    }
4565
4566    #[rstest]
4567    fn test_parse_trigger_order_from_regular_channel() {
4568        let json_data = load_test_json("ws_orders_trigger.json");
4569        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4570        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4571
4572        // Test triggered order that came through regular orders channel
4573        let msg = &data[0];
4574        assert_eq!(msg.ord_type, OKXOrderType::Trigger);
4575        assert_eq!(msg.state, OKXOrderStatus::Filled);
4576
4577        let account_id = AccountId::new("OKX-001");
4578        let mut instruments = AHashMap::new();
4579
4580        // Create mock instrument
4581        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4582        let instrument = CryptoPerpetual::builder()
4583            .instrument_id(instrument_id)
4584            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
4585            .base_currency(Currency::BTC())
4586            .quote_currency(Currency::USDT())
4587            .settlement_currency(Currency::USDT())
4588            .is_inverse(false)
4589            .price_precision(2)
4590            .size_precision(8)
4591            .price_increment(Price::from("0.01"))
4592            .size_increment(Quantity::from("0.00000001"))
4593            .ts_event(0.into())
4594            .ts_init(0.into())
4595            .build()
4596            .unwrap();
4597        instruments.insert(
4598            Ustr::from("BTC-USDT-SWAP"),
4599            InstrumentAny::CryptoPerpetual(instrument),
4600        );
4601
4602        let mut fee_cache = AHashMap::new();
4603        let mut filled_qty_cache = AHashMap::new();
4604
4605        let result = parse_order_msg_vec(
4606            std::slice::from_ref(msg),
4607            account_id,
4608            &instruments,
4609            &mut fee_cache,
4610            &mut filled_qty_cache,
4611            UnixNanos::default(),
4612        );
4613
4614        assert!(result.is_ok());
4615        let reports = result.unwrap();
4616        assert_eq!(reports.len(), 1);
4617
4618        if let ExecutionReport::Fill(fill_report) = &reports[0] {
4619            assert_eq!(fill_report.order_side, OrderSide::Sell);
4620            assert_eq!(fill_report.last_qty, Quantity::from("0.01000000"));
4621            assert_eq!(fill_report.last_px, Price::from("101950.00"));
4622        } else {
4623            panic!("Expected Fill report for filled trigger order");
4624        }
4625    }
4626
4627    #[rstest]
4628    fn test_parse_liquidation_order() {
4629        let json_data = load_test_json("ws_orders_liquidation.json");
4630        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4631        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4632
4633        // Test liquidation order
4634        let msg = &data[0];
4635        assert_eq!(msg.category, OKXOrderCategory::FullLiquidation);
4636        assert_eq!(msg.state, OKXOrderStatus::Filled);
4637        assert_eq!(msg.inst_id.as_str(), "BTC-USDT-SWAP");
4638
4639        let account_id = AccountId::new("OKX-001");
4640        let mut instruments = AHashMap::new();
4641
4642        // Create mock instrument
4643        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4644        let instrument = CryptoPerpetual::builder()
4645            .instrument_id(instrument_id)
4646            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
4647            .base_currency(Currency::BTC())
4648            .quote_currency(Currency::USDT())
4649            .settlement_currency(Currency::USDT())
4650            .is_inverse(false)
4651            .price_precision(2)
4652            .size_precision(8)
4653            .price_increment(Price::from("0.01"))
4654            .size_increment(Quantity::from("0.00000001"))
4655            .ts_event(0.into())
4656            .ts_init(0.into())
4657            .build()
4658            .unwrap();
4659        instruments.insert(
4660            Ustr::from("BTC-USDT-SWAP"),
4661            InstrumentAny::CryptoPerpetual(instrument),
4662        );
4663        let mut fee_cache = AHashMap::new();
4664        let mut filled_qty_cache = AHashMap::new();
4665
4666        let result = parse_order_msg_vec(
4667            std::slice::from_ref(msg),
4668            account_id,
4669            &instruments,
4670            &mut fee_cache,
4671            &mut filled_qty_cache,
4672            UnixNanos::default(),
4673        );
4674
4675        assert!(result.is_ok());
4676        let reports = result.unwrap();
4677        assert_eq!(reports.len(), 1);
4678
4679        // Verify it's a fill report for a liquidation
4680        if let ExecutionReport::Fill(fill_report) = &reports[0] {
4681            assert_eq!(fill_report.order_side, OrderSide::Sell);
4682            assert_eq!(fill_report.last_qty, Quantity::from("0.50000000"));
4683            assert_eq!(fill_report.last_px, Price::from("40000.00"));
4684            assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
4685        } else {
4686            panic!("Expected Fill report for liquidation order");
4687        }
4688    }
4689
4690    #[rstest]
4691    fn test_parse_adl_order() {
4692        let json_data = load_test_json("ws_orders_adl.json");
4693        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4694        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4695
4696        // Test ADL order
4697        let msg = &data[0];
4698        assert_eq!(msg.category, OKXOrderCategory::Adl);
4699        assert_eq!(msg.state, OKXOrderStatus::Filled);
4700        assert_eq!(msg.inst_id.as_str(), "ETH-USDT-SWAP");
4701
4702        let account_id = AccountId::new("OKX-001");
4703        let mut instruments = AHashMap::new();
4704
4705        // Create mock instrument
4706        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
4707        let instrument = CryptoPerpetual::builder()
4708            .instrument_id(instrument_id)
4709            .raw_symbol(Symbol::from("ETH-USDT-SWAP"))
4710            .base_currency(Currency::ETH())
4711            .quote_currency(Currency::USDT())
4712            .settlement_currency(Currency::USDT())
4713            .is_inverse(false)
4714            .price_precision(2)
4715            .size_precision(8)
4716            .price_increment(Price::from("0.01"))
4717            .size_increment(Quantity::from("0.00000001"))
4718            .ts_event(0.into())
4719            .ts_init(0.into())
4720            .build()
4721            .unwrap();
4722        instruments.insert(
4723            Ustr::from("ETH-USDT-SWAP"),
4724            InstrumentAny::CryptoPerpetual(instrument),
4725        );
4726
4727        let mut fee_cache = AHashMap::new();
4728        let mut filled_qty_cache = AHashMap::new();
4729
4730        let result = parse_order_msg_vec(
4731            std::slice::from_ref(msg),
4732            account_id,
4733            &instruments,
4734            &mut fee_cache,
4735            &mut filled_qty_cache,
4736            UnixNanos::default(),
4737        );
4738
4739        assert!(result.is_ok());
4740        let reports = result.unwrap();
4741        assert_eq!(reports.len(), 1);
4742
4743        // Verify it's a fill report for ADL
4744        if let ExecutionReport::Fill(fill_report) = &reports[0] {
4745            assert_eq!(fill_report.order_side, OrderSide::Buy);
4746            assert_eq!(fill_report.last_qty, Quantity::from("0.30000000"));
4747            assert_eq!(fill_report.last_px, Price::from("41000.00"));
4748            assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
4749        } else {
4750            panic!("Expected Fill report for ADL order");
4751        }
4752    }
4753
4754    #[rstest]
4755    fn test_parse_unknown_category_graceful_fallback() {
4756        // Test that unknown/future category values deserialize as Other instead of failing
4757        let json_with_unknown_category = r#"{
4758            "category": "some_future_category_we_dont_know"
4759        }"#;
4760
4761        let result: Result<serde_json::Value, _> = serde_json::from_str(json_with_unknown_category);
4762        result.unwrap();
4763
4764        // Test deserialization of the category field directly
4765        let category_result: Result<OKXOrderCategory, _> =
4766            serde_json::from_str(r#""some_future_category""#);
4767        assert!(category_result.is_ok());
4768        assert_eq!(category_result.unwrap(), OKXOrderCategory::Other);
4769
4770        // Verify known categories still work
4771        let normal: OKXOrderCategory = serde_json::from_str(r#""normal""#).unwrap();
4772        assert_eq!(normal, OKXOrderCategory::Normal);
4773
4774        let twap: OKXOrderCategory = serde_json::from_str(r#""twap""#).unwrap();
4775        assert_eq!(twap, OKXOrderCategory::Twap);
4776    }
4777
4778    #[rstest]
4779    fn test_parse_partial_liquidation_order() {
4780        // Create a test message with partial liquidation category
4781        let account_id = AccountId::new("OKX-001");
4782        let mut instruments = AHashMap::new();
4783
4784        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
4785        let instrument = CryptoPerpetual::builder()
4786            .instrument_id(instrument_id)
4787            .raw_symbol(Symbol::from("BTC-USDT-SWAP"))
4788            .base_currency(Currency::BTC())
4789            .quote_currency(Currency::USDT())
4790            .settlement_currency(Currency::USDT())
4791            .is_inverse(false)
4792            .price_precision(2)
4793            .size_precision(8)
4794            .price_increment(Price::from("0.01"))
4795            .size_increment(Quantity::from("0.00000001"))
4796            .ts_event(0.into())
4797            .ts_init(0.into())
4798            .build()
4799            .unwrap();
4800        instruments.insert(
4801            Ustr::from("BTC-USDT-SWAP"),
4802            InstrumentAny::CryptoPerpetual(instrument),
4803        );
4804
4805        let partial_liq_msg = OKXOrderMsg {
4806            acc_fill_sz: Some("0.25".to_string()),
4807            algo_id: None,
4808            avg_px: "39000.0".to_string(),
4809            c_time: 1746947317401,
4810            cancel_source: None,
4811            cancel_source_reason: None,
4812            category: OKXOrderCategory::PartialLiquidation,
4813            ccy: Ustr::from("USDT"),
4814            cl_ord_id: String::new(),
4815            algo_cl_ord_id: None,
4816            attach_algo_cl_ord_id: None,
4817            attach_algo_ords: Vec::new(),
4818            outcome: None,
4819            fee: Some("-9.75".to_string()),
4820            fee_ccy: Ustr::from("USDT"),
4821            fill_fee: None,
4822            fill_fee_ccy: None,
4823            fill_mark_px: None,
4824            fill_mark_vol: None,
4825            fill_px_vol: None,
4826            fill_px_usd: None,
4827            fill_fwd_px: None,
4828            fill_notional_usd: None,
4829            fill_pnl: None,
4830            fill_px: "39000.0".to_string(),
4831            fill_sz: "0.25".to_string(),
4832            fill_time: 1746947317402,
4833            inst_id: Ustr::from("BTC-USDT-SWAP"),
4834            inst_type: OKXInstrumentType::Swap,
4835            is_tp_limit: None,
4836            lever: "10.0".to_string(),
4837            linked_algo_ord: None,
4838            notional_usd: None,
4839            ord_id: Ustr::from("2497956918703120888"),
4840            ord_type: OKXOrderType::Market,
4841            pnl: "-2500".to_string(),
4842            pos_side: OKXPositionSide::Long,
4843            px: String::new(),
4844            px_type: OKXPriceType::None,
4845            px_usd: None,
4846            px_vol: None,
4847            quick_mgn_type: OKXQuickMarginType::None,
4848            rebate: None,
4849            rebate_ccy: None,
4850            reduce_only: "false".to_string(),
4851            side: OKXSide::Sell,
4852            sl_ord_px: None,
4853            sl_trigger_px: None,
4854            sl_trigger_px_type: None,
4855            source: None,
4856            state: OKXOrderStatus::Filled,
4857            stp_id: None,
4858            stp_mode: OKXSelfTradePreventionMode::None,
4859            exec_type: OKXExecType::Taker,
4860            sz: "0.25".to_string(),
4861            tag: None,
4862            td_mode: OKXTradeMode::Isolated,
4863            tgt_ccy: None,
4864            tp_ord_px: None,
4865            tp_trigger_px: None,
4866            tp_trigger_px_type: None,
4867            trade_id: "1518905888".to_string(),
4868            u_time: 1746947317402,
4869            amend_result: None,
4870            req_id: None,
4871            code: None,
4872            msg: None,
4873        };
4874
4875        let fee_cache = AHashMap::new();
4876        let filled_qty_cache = AHashMap::new();
4877        let result = parse_order_msg(
4878            &partial_liq_msg,
4879            account_id,
4880            &instruments,
4881            &fee_cache,
4882            &filled_qty_cache,
4883            UnixNanos::default(),
4884        );
4885
4886        assert!(result.is_ok());
4887        let report = result.unwrap();
4888
4889        // Verify it's a fill report for partial liquidation
4890        if let ExecutionReport::Fill(fill_report) = report {
4891            assert_eq!(fill_report.order_side, OrderSide::Sell);
4892            assert_eq!(fill_report.last_qty, Quantity::from("0.25000000"));
4893            assert_eq!(fill_report.last_px, Price::from("39000.00"));
4894        } else {
4895            panic!("Expected Fill report for partial liquidation order");
4896        }
4897    }
4898
4899    #[rstest]
4900    fn test_parse_mmp_canceled_order_message() {
4901        use nautilus_model::instruments::stubs::crypto_option_btc_deribit;
4902
4903        let json_data = load_test_json("ws_orders_mmp_canceled.json");
4904        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4905        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4906
4907        let msg = &data[0];
4908        assert_eq!(msg.state, OKXOrderStatus::MmpCanceled);
4909        assert_eq!(msg.ord_type, OKXOrderType::MmpAndPostOnly);
4910
4911        let account_id = AccountId::new("OKX-001");
4912        let mut instruments = AHashMap::new();
4913        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
4914        let mut option =
4915            crypto_option_btc_deribit(3, 1, Price::from("0.001"), Quantity::from("0.1"));
4916        option.id = instrument_id;
4917        option.raw_symbol = Symbol::from("BTC-USD-250328-92000-C");
4918        instruments.insert(
4919            Ustr::from("BTC-USD-250328-92000-C"),
4920            InstrumentAny::CryptoOption(option),
4921        );
4922
4923        let fee_cache = AHashMap::new();
4924        let filled_qty_cache = AHashMap::new();
4925        let report = parse_order_msg(
4926            msg,
4927            account_id,
4928            &instruments,
4929            &fee_cache,
4930            &filled_qty_cache,
4931            UnixNanos::default(),
4932        )
4933        .unwrap();
4934
4935        match report {
4936            ExecutionReport::Order(report) => {
4937                // MMP cancels map to Canceled with the venue reason preserved
4938                assert_eq!(report.order_status, OrderStatus::Canceled);
4939                assert_eq!(report.instrument_id, instrument_id);
4940                assert!(
4941                    report
4942                        .cancel_reason
4943                        .as_ref()
4944                        .is_some_and(|reason| reason.as_str().contains("market maker protection")),
4945                );
4946            }
4947            other => panic!("Expected Order report for MMP-canceled order, was {other:?}"),
4948        }
4949    }
4950
4951    #[rstest]
4952    fn test_parse_order_msg_unknown_state_preserves_fill() {
4953        let json_data = load_test_json("ws_orders_unknown_state_fill.json");
4954        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4955        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4956
4957        // Unknown states fall back to `Unknown` instead of failing deserialization
4958        let msg = &data[0];
4959        assert_eq!(msg.state, OKXOrderStatus::Unknown);
4960
4961        let account_id = AccountId::new("OKX-001");
4962        let mut instruments = AHashMap::new();
4963        instruments.insert(
4964            Ustr::from("BTC-USDT-SWAP"),
4965            InstrumentAny::CryptoPerpetual(create_stub_instrument()),
4966        );
4967
4968        let fee_cache = AHashMap::new();
4969        let filled_qty_cache = AHashMap::new();
4970        let report = parse_order_msg(
4971            msg,
4972            account_id,
4973            &instruments,
4974            &fee_cache,
4975            &filled_qty_cache,
4976            UnixNanos::default(),
4977        )
4978        .unwrap();
4979
4980        // Fill data is processed even when the state itself is unrecognized
4981        match report {
4982            ExecutionReport::Fill(fill_report) => {
4983                assert_eq!(fill_report.order_side, OrderSide::Buy);
4984                assert_eq!(fill_report.last_qty, Quantity::from("0.25000000"));
4985                assert_eq!(fill_report.last_px, Price::from("40000.00"));
4986                assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
4987            }
4988            other => panic!("Expected Fill report for unknown-state order, was {other:?}"),
4989        }
4990    }
4991
4992    #[rstest]
4993    fn test_parse_order_msg_unknown_state_without_fill_errors() {
4994        let json_data = load_test_json("ws_orders_unknown_state_fill.json");
4995        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
4996        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
4997
4998        let mut msg = data[0].clone();
4999        msg.fill_sz = "0".to_string();
5000        msg.trade_id = String::new();
5001        msg.acc_fill_sz = Some("0".to_string());
5002
5003        let account_id = AccountId::new("OKX-001");
5004        let mut instruments = AHashMap::new();
5005        instruments.insert(
5006            Ustr::from("BTC-USDT-SWAP"),
5007            InstrumentAny::CryptoPerpetual(create_stub_instrument()),
5008        );
5009
5010        let fee_cache = AHashMap::new();
5011        let filled_qty_cache = AHashMap::new();
5012        let result = parse_order_msg(
5013            &msg,
5014            account_id,
5015            &instruments,
5016            &fee_cache,
5017            &filled_qty_cache,
5018            UnixNanos::default(),
5019        );
5020
5021        // Without fill data there is nothing safe to emit for an unrecognized state
5022        assert!(result.is_err());
5023    }
5024
5025    #[rstest]
5026    fn test_parse_order_msg_unknown_order_type_preserves_fill() {
5027        let json_data = load_test_json("ws_orders_unknown_ord_type_fill.json");
5028        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5029        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
5030
5031        // Unknown order types fall back to `Other` instead of failing deserialization
5032        let msg = &data[0];
5033        assert_eq!(msg.ord_type, OKXOrderType::Other);
5034
5035        let account_id = AccountId::new("OKX-001");
5036        let mut instruments = AHashMap::new();
5037        instruments.insert(
5038            Ustr::from("BTC-USDT-SWAP"),
5039            InstrumentAny::CryptoPerpetual(create_stub_instrument()),
5040        );
5041
5042        let fee_cache = AHashMap::new();
5043        let filled_qty_cache = AHashMap::new();
5044        let report = parse_order_msg(
5045            msg,
5046            account_id,
5047            &instruments,
5048            &fee_cache,
5049            &filled_qty_cache,
5050            UnixNanos::default(),
5051        )
5052        .unwrap();
5053
5054        match report {
5055            ExecutionReport::Fill(fill_report) => {
5056                assert_eq!(fill_report.order_side, OrderSide::Sell);
5057                assert_eq!(fill_report.last_qty, Quantity::from("0.25000000"));
5058                assert_eq!(fill_report.last_px, Price::from("40000.00"));
5059            }
5060            other => panic!("Expected Fill report for unknown-ord-type order, was {other:?}"),
5061        }
5062    }
5063
5064    #[rstest]
5065    fn test_deserialize_liquidation_warning_message() {
5066        let json_data = load_test_json("ws_liquidation_warning.json");
5067        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5068        let data: Vec<OKXLiquidationWarningMsg> =
5069            serde_json::from_value(payload["data"].clone()).unwrap();
5070
5071        assert_eq!(data.len(), 1);
5072        let warning = &data[0];
5073        assert_eq!(warning.inst_id, Ustr::from("BTC-USDT-SWAP"));
5074        assert_eq!(warning.inst_type, OKXInstrumentType::Swap);
5075        assert_eq!(warning.mgn_mode, OKXMarginMode::Cross);
5076        assert_eq!(warning.pos_side, OKXPositionSide::Long);
5077        assert_eq!(warning.pos, "0.5");
5078        assert_eq!(warning.mgn_ratio, "0.62");
5079        assert_eq!(warning.mark_px, "41250.5");
5080        assert_eq!(warning.c_time, 1622559930237);
5081        assert_eq!(warning.u_time, 1788000000001);
5082        assert_eq!(warning.p_time.as_deref(), Some("1788000000002"));
5083    }
5084
5085    #[rstest]
5086    fn test_websocket_instrument_update_preserves_cached_fees() {
5087        use nautilus_model::{identifiers::InstrumentId, instruments::InstrumentAny};
5088
5089        use crate::common::{models::OKXInstrument, parse::parse_instrument_any};
5090
5091        let ts_init = UnixNanos::default();
5092
5093        // Create initial instrument with fees (simulating HTTP load)
5094        // These values are already in Nautilus format (HTTP client negates OKX values)
5095        let initial_fees = (
5096            Some(Decimal::new(8, 4)),  // Nautilus: 0.0008 (commission)
5097            Some(Decimal::new(10, 4)), // Nautilus: 0.0010 (commission)
5098        );
5099
5100        // Deserialize initial instrument from JSON
5101        let initial_inst_json = serde_json::json!({
5102            "instType": "SPOT",
5103            "instId": "BTC-USD",
5104            "baseCcy": "BTC",
5105            "quoteCcy": "USD",
5106            "settleCcy": "",
5107            "ctVal": "",
5108            "ctMult": "",
5109            "ctValCcy": "",
5110            "optType": "",
5111            "stk": "",
5112            "listTime": "1733454000000",
5113            "expTime": "",
5114            "lever": "",
5115            "tickSz": "0.1",
5116            "lotSz": "0.00000001",
5117            "minSz": "0.00001",
5118            "ctType": "linear",
5119            "alias": "",
5120            "state": "live",
5121            "maxLmtSz": "9999999999",
5122            "maxMktSz": "1000000",
5123            "maxTwapSz": "9999999999.0000000000000000",
5124            "maxIcebergSz": "9999999999.0000000000000000",
5125            "maxTriggerSz": "9999999999.0000000000000000",
5126            "maxStopSz": "1000000",
5127            "uly": "",
5128            "instFamily": "",
5129            "ruleType": "normal",
5130            "maxLmtAmt": "20000000",
5131            "maxMktAmt": "1000000"
5132        });
5133
5134        let initial_inst: OKXInstrument = serde_json::from_value(initial_inst_json)
5135            .expect("Failed to deserialize initial instrument");
5136
5137        // Parse initial instrument with fees
5138        let parsed_initial = parse_instrument_any(
5139            &initial_inst,
5140            None,
5141            None,
5142            initial_fees.0,
5143            initial_fees.1,
5144            ts_init,
5145        )
5146        .expect("Failed to parse initial instrument")
5147        .expect("Initial instrument should not be None");
5148
5149        // Verify fees were applied
5150        if let InstrumentAny::CurrencyPair(ref pair) = parsed_initial {
5151            assert_eq!(pair.maker_fee, dec!(0.0008));
5152            assert_eq!(pair.taker_fee, dec!(0.0010));
5153        } else {
5154            panic!("Expected CurrencyPair instrument");
5155        }
5156
5157        // Build instrument cache with the initial instrument
5158        let mut instruments_cache = AHashMap::new();
5159        instruments_cache.insert(Ustr::from("BTC-USD"), parsed_initial);
5160
5161        // Create WebSocket update message (same structure as initial, simulating a WebSocket update)
5162        let ws_update = serde_json::json!({
5163            "instType": "SPOT",
5164            "instId": "BTC-USD",
5165            "baseCcy": "BTC",
5166            "quoteCcy": "USD",
5167            "settleCcy": "",
5168            "ctVal": "",
5169            "ctMult": "",
5170            "ctValCcy": "",
5171            "optType": "",
5172            "stk": "",
5173            "listTime": "1733454000000",
5174            "expTime": "",
5175            "lever": "",
5176            "tickSz": "0.1",
5177            "lotSz": "0.00000001",
5178            "minSz": "0.00001",
5179            "ctType": "linear",
5180            "alias": "",
5181            "state": "live",
5182            "maxLmtSz": "9999999999",
5183            "maxMktSz": "1000000",
5184            "maxTwapSz": "9999999999.0000000000000000",
5185            "maxIcebergSz": "9999999999.0000000000000000",
5186            "maxTriggerSz": "9999999999.0000000000000000",
5187            "maxStopSz": "1000000",
5188            "uly": "",
5189            "instFamily": "",
5190            "ruleType": "normal",
5191            "maxLmtAmt": "20000000",
5192            "maxMktAmt": "1000000"
5193        });
5194
5195        let instrument_id = InstrumentId::from("BTC-USD.OKX");
5196        let mut funding_cache = AHashMap::new();
5197
5198        // Parse WebSocket update with cache
5199        let result = parse_ws_message_data(
5200            &OKXWsChannel::Instruments,
5201            ws_update,
5202            &instrument_id,
5203            2,
5204            8,
5205            ts_init,
5206            &mut funding_cache,
5207            &instruments_cache,
5208        )
5209        .expect("Failed to parse WebSocket instrument update");
5210
5211        // Verify the update preserves the cached fees
5212        if let Some(NautilusWsMessage::Instrument(boxed_inst, _status)) = result {
5213            if let InstrumentAny::CurrencyPair(pair) = *boxed_inst {
5214                assert_eq!(
5215                    pair.maker_fee,
5216                    Decimal::new(8, 4),
5217                    "Maker fee should be preserved from cache"
5218                );
5219                assert_eq!(
5220                    pair.taker_fee,
5221                    Decimal::new(10, 4),
5222                    "Taker fee should be preserved from cache"
5223                );
5224            } else {
5225                panic!("Expected CurrencyPair instrument from WebSocket update");
5226            }
5227        } else {
5228            panic!("Expected Instrument message from WebSocket update");
5229        }
5230    }
5231
5232    #[rstest]
5233    #[case::fok_order(OKXOrderType::Fok, TimeInForce::Fok)]
5234    #[case::ioc_order(OKXOrderType::Ioc, TimeInForce::Ioc)]
5235    #[case::optimal_limit_ioc_order(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
5236    #[case::market_order(OKXOrderType::Market, TimeInForce::Gtc)]
5237    #[case::limit_order(OKXOrderType::Limit, TimeInForce::Gtc)]
5238    fn test_parse_time_in_force_from_ord_type(
5239        #[case] okx_ord_type: OKXOrderType,
5240        #[case] expected_tif: TimeInForce,
5241    ) {
5242        let time_in_force = match okx_ord_type {
5243            OKXOrderType::Fok | OKXOrderType::OpFok => TimeInForce::Fok,
5244            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
5245            _ => TimeInForce::Gtc,
5246        };
5247
5248        assert_eq!(
5249            time_in_force, expected_tif,
5250            "OKXOrderType::{okx_ord_type:?} should parse to TimeInForce::{expected_tif:?}"
5251        );
5252    }
5253
5254    #[rstest]
5255    fn test_deserialize_fok_order_message() {
5256        let json_data = load_test_json("ws_orders_fok.json");
5257        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5258        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
5259
5260        assert!(!data.is_empty());
5261        assert_eq!(data[0].ord_type, OKXOrderType::Fok);
5262        assert_eq!(data[0].cl_ord_id, "FOK-TEST-001");
5263        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT"));
5264    }
5265
5266    #[rstest]
5267    fn test_deserialize_ioc_order_message() {
5268        let json_data = load_test_json("ws_orders_ioc.json");
5269        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5270        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
5271
5272        assert!(!data.is_empty());
5273        assert_eq!(data[0].ord_type, OKXOrderType::Ioc);
5274        assert_eq!(data[0].cl_ord_id, "IOC-TEST-001");
5275        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT"));
5276    }
5277
5278    #[rstest]
5279    fn test_deserialize_optimal_limit_ioc_order_message() {
5280        let json_data = load_test_json("ws_orders_optimal_limit_ioc.json");
5281        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5282        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
5283
5284        assert!(!data.is_empty());
5285        assert_eq!(data[0].ord_type, OKXOrderType::OptimalLimitIoc);
5286        assert_eq!(data[0].cl_ord_id, "OPTIMAL-IOC-TEST-001");
5287        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
5288    }
5289
5290    #[rstest]
5291    fn test_deserialize_regular_order_message() {
5292        let json_data = load_test_json("ws_orders.json");
5293        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5294        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5295
5296        assert!(!data.is_empty());
5297        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
5298        assert_eq!(data[0].state, OKXOrderStatus::Filled);
5299        assert_eq!(data[0].category, OKXOrderCategory::Normal);
5300        assert_eq!(data[0].rebate.as_deref(), Some("0"));
5301        assert_eq!(data[0].rebate_ccy.as_deref(), Some("USDT"));
5302        assert_eq!(data[0].stp_mode, OKXSelfTradePreventionMode::CancelMaker);
5303        assert!(data[0].linked_algo_ord.is_some());
5304        assert_eq!(data[0].tag.as_deref(), Some(""));
5305        assert_eq!(data[0].source.as_deref(), Some(""));
5306    }
5307
5308    #[rstest]
5309    fn test_deserialize_algo_order_message() {
5310        let json_data = load_test_json("ws_orders_algo.json");
5311        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5312        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5313
5314        assert!(!data.is_empty());
5315        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
5316    }
5317
5318    #[rstest]
5319    fn test_deserialize_algo_order_missing_trigger_px_type() {
5320        // algo-advance channel messages omit triggerPxType
5321        let json = r#"{
5322            "algoId": "123",
5323            "algoClOrdId": "cl_1",
5324            "clOrdId": "",
5325            "ordId": "",
5326            "instId": "BTC-USDT-SWAP",
5327            "instType": "SWAP",
5328            "ordType": "move_order_stop",
5329            "state": "live",
5330            "side": "sell",
5331            "posSide": "long",
5332            "sz": "0.01",
5333            "triggerPx": "95000",
5334            "ordPx": "-1",
5335            "tdMode": "cross",
5336            "lever": "",
5337            "reduceOnly": "false",
5338            "actualPx": "",
5339            "actualSz": "",
5340            "notionalUsd": "",
5341            "cTime": "1706000000000",
5342            "uTime": "1706000001000",
5343            "triggerTime": "",
5344            "tag": "",
5345            "callbackRatio": "0.01",
5346            "callbackSpread": "",
5347            "activePx": ""
5348        }"#;
5349
5350        let msg: OKXAlgoOrderMsg = serde_json::from_str(json).unwrap();
5351
5352        assert_eq!(msg.trigger_px_type, OKXTriggerType::None);
5353        assert_eq!(msg.ord_type, OKXAlgoOrderType::MoveOrderStop);
5354    }
5355
5356    #[rstest]
5357    fn test_deserialize_liquidation_order_message() {
5358        let json_data = load_test_json("ws_orders_liquidation.json");
5359        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5360        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5361
5362        assert!(!data.is_empty());
5363        assert_eq!(data[0].category, OKXOrderCategory::FullLiquidation);
5364    }
5365
5366    #[rstest]
5367    fn test_deserialize_adl_order_message() {
5368        let json_data = load_test_json("ws_orders_adl.json");
5369        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5370        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5371
5372        assert!(!data.is_empty());
5373        assert_eq!(data[0].category, OKXOrderCategory::Adl);
5374    }
5375
5376    #[rstest]
5377    fn test_deserialize_trigger_order_message() {
5378        let json_data = load_test_json("ws_orders_trigger.json");
5379        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5380        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5381
5382        assert!(!data.is_empty());
5383        assert_eq!(data[0].ord_type, OKXOrderType::Trigger);
5384        assert_eq!(data[0].category, OKXOrderCategory::Normal);
5385    }
5386
5387    #[rstest]
5388    fn test_deserialize_book_snapshot_message() {
5389        let json_data = load_test_json("ws_books_snapshot.json");
5390        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5391        let action: Option<OKXBookAction> =
5392            serde_json::from_value(payload["action"].clone()).unwrap();
5393        let data: Vec<OKXBookMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5394
5395        assert!(!data.is_empty());
5396        assert_eq!(action, Some(OKXBookAction::Snapshot));
5397        assert!(!data[0].asks.is_empty());
5398        assert!(!data[0].bids.is_empty());
5399    }
5400
5401    #[rstest]
5402    fn test_deserialize_book_update_message() {
5403        let json_data = load_test_json("ws_books_update.json");
5404        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5405        let action: Option<OKXBookAction> =
5406            serde_json::from_value(payload["action"].clone()).unwrap();
5407        let data: Vec<OKXBookMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5408
5409        assert!(!data.is_empty());
5410        assert_eq!(action, Some(OKXBookAction::Update));
5411        assert!(!data[0].asks.is_empty());
5412        assert!(!data[0].bids.is_empty());
5413    }
5414
5415    #[rstest]
5416    fn test_deserialize_ticker_message() {
5417        let json_data = load_test_json("ws_tickers.json");
5418        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5419        let data: Vec<OKXTickerMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5420
5421        assert!(!data.is_empty());
5422        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT"));
5423        assert_eq!(data[0].last_px, "9999.99");
5424    }
5425
5426    #[rstest]
5427    fn test_deserialize_candle_message() {
5428        let json_data = load_test_json("ws_candle.json");
5429        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5430        let data: Vec<OKXCandleMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5431
5432        assert!(!data.is_empty());
5433        assert!(!data[0].o.is_empty());
5434        assert!(!data[0].h.is_empty());
5435        assert!(!data[0].l.is_empty());
5436        assert!(!data[0].c.is_empty());
5437    }
5438
5439    #[rstest]
5440    fn test_deserialize_funding_rate_message() {
5441        let json_data = load_test_json("ws_funding_rate.json");
5442        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5443        let data: Vec<OKXFundingRateMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5444
5445        assert!(!data.is_empty());
5446        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
5447    }
5448
5449    #[rstest]
5450    fn test_deserialize_bbo_tbt_message() {
5451        let json_data = load_test_json("ws_bbo_tbt.json");
5452        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5453        let data: Vec<OKXBookMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5454
5455        assert!(!data.is_empty());
5456        assert!(!data[0].asks.is_empty());
5457        assert!(!data[0].bids.is_empty());
5458    }
5459
5460    #[rstest]
5461    fn test_deserialize_trade_message() {
5462        let json_data = load_test_json("ws_trades.json");
5463        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
5464        let data: Vec<OKXTradeMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
5465
5466        assert!(!data.is_empty());
5467        assert_eq!(data[0].inst_id, Ustr::from("BTC-USD"));
5468    }
5469
5470    fn create_order_msg_for_event_test(
5471        state: OKXOrderStatus,
5472        cl_ord_id: &str,
5473        ord_id: &str,
5474        px: &str,
5475        sz: &str,
5476    ) -> OKXOrderMsg {
5477        OKXOrderMsg {
5478            acc_fill_sz: Some("0".to_string()),
5479            algo_id: None,
5480            avg_px: "50000.0".to_string(),
5481            c_time: 1746947317401,
5482            cancel_source: None,
5483            cancel_source_reason: None,
5484            category: OKXOrderCategory::Normal,
5485            ccy: Ustr::from("USDT"),
5486            cl_ord_id: cl_ord_id.to_string(),
5487            algo_cl_ord_id: None,
5488            attach_algo_cl_ord_id: None,
5489            attach_algo_ords: Vec::new(),
5490            outcome: None,
5491            fee: Some("0".to_string()),
5492            fee_ccy: Ustr::from("USDT"),
5493            fill_fee: None,
5494            fill_fee_ccy: None,
5495            fill_mark_px: None,
5496            fill_mark_vol: None,
5497            fill_px_vol: None,
5498            fill_px_usd: None,
5499            fill_fwd_px: None,
5500            fill_notional_usd: None,
5501            fill_pnl: None,
5502            fill_px: String::new(),
5503            fill_sz: String::new(),
5504            fill_time: 0,
5505            inst_id: Ustr::from("BTC-USDT-SWAP"),
5506            inst_type: OKXInstrumentType::Swap,
5507            is_tp_limit: None,
5508            lever: "2.0".to_string(),
5509            linked_algo_ord: None,
5510            notional_usd: None,
5511            ord_id: Ustr::from(ord_id),
5512            ord_type: OKXOrderType::Limit,
5513            pnl: "0".to_string(),
5514            pos_side: OKXPositionSide::Long,
5515            px: px.to_string(),
5516            px_type: OKXPriceType::None,
5517            px_usd: None,
5518            px_vol: None,
5519            quick_mgn_type: OKXQuickMarginType::None,
5520            rebate: None,
5521            rebate_ccy: None,
5522            reduce_only: "false".to_string(),
5523            side: OKXSide::Buy,
5524            sl_ord_px: None,
5525            sl_trigger_px: None,
5526            sl_trigger_px_type: None,
5527            source: None,
5528            state,
5529            stp_id: None,
5530            stp_mode: OKXSelfTradePreventionMode::None,
5531            exec_type: OKXExecType::Taker,
5532            sz: sz.to_string(),
5533            tag: None,
5534            td_mode: OKXTradeMode::Isolated,
5535            tgt_ccy: None,
5536            tp_ord_px: None,
5537            tp_trigger_px: None,
5538            tp_trigger_px_type: None,
5539            trade_id: String::new(),
5540            u_time: 1746947317402,
5541            amend_result: None,
5542            req_id: None,
5543            code: None,
5544            msg: None,
5545        }
5546    }
5547
5548    fn create_spread_order_msg_for_event_test(
5549        state: OKXOrderStatus,
5550        cl_ord_id: &str,
5551        ord_id: &str,
5552        px: &str,
5553        sz: &str,
5554    ) -> OKXSpreadOrder {
5555        OKXSpreadOrder {
5556            sprd_id: Ustr::from("BTC-USDT_BTC-USDT-SWAP"),
5557            ord_id: Ustr::from(ord_id),
5558            cl_ord_id: Ustr::from(cl_ord_id),
5559            tag: String::new(),
5560            side: OKXSide::Buy,
5561            ord_type: OKXOrderType::Limit,
5562            sz: sz.to_string(),
5563            px: px.to_string(),
5564            avg_px: String::new(),
5565            state,
5566            acc_fill_sz: "0".to_string(),
5567            pending_fill_sz: "0".to_string(),
5568            pending_settle_sz: "0".to_string(),
5569            canceled_sz: "0".to_string(),
5570            fill_sz: String::new(),
5571            fill_px: String::new(),
5572            trade_id: Ustr::default(),
5573            cancel_source: String::new(),
5574            req_id: String::new(),
5575            amend_result: String::new(),
5576            code: String::new(),
5577            msg: String::new(),
5578            c_time: Some(1_746_947_317_401),
5579            u_time: Some(1_746_947_317_402),
5580        }
5581    }
5582
5583    #[rstest]
5584    fn test_deserialize_spread_orders_message() {
5585        let json_data = load_test_json("ws_sprd_orders.json");
5586        let frame: OKXWsFrame = serde_json::from_str(&json_data).unwrap();
5587
5588        match frame {
5589            OKXWsFrame::Data { arg, data } => {
5590                let orders: Vec<OKXSpreadOrder> = serde_json::from_value(data).unwrap();
5591
5592                assert_eq!(arg.channel, OKXWsChannel::SprdOrders);
5593                assert_eq!(orders.len(), 1);
5594                assert_eq!(orders[0].sprd_id, Ustr::from("BCH-USDT_BCH-USDT-SWAP"));
5595                assert_eq!(orders[0].ord_id, Ustr::from("3386544889978159104"));
5596                assert_eq!(orders[0].state, OKXOrderStatus::Live);
5597            }
5598            other => panic!("Expected Data, was {other:?}"),
5599        }
5600    }
5601
5602    #[rstest]
5603    fn test_synthesize_trade_id_is_deterministic_and_under_36_chars() {
5604        let mut msg = create_order_msg_for_event_test(
5605            OKXOrderStatus::Filled,
5606            "client-1",
5607            "venue-1",
5608            "50000.0",
5609            "0.001",
5610        );
5611        msg.fill_px = "50000.0".to_string();
5612        msg.fill_sz = "0.001".to_string();
5613        msg.fill_time = 1_746_947_317_500;
5614        msg.acc_fill_sz = Some("0.001".to_string());
5615
5616        let id1 = synthesize_trade_id(&msg);
5617        let id2 = synthesize_trade_id(&msg);
5618
5619        assert_eq!(id1, id2, "synthesized id must be deterministic");
5620        assert!(
5621            id1.len() <= 36,
5622            "synthesized id must fit in TradeId, was {}",
5623            id1.len()
5624        );
5625        assert!(id1.starts_with("synth-"));
5626    }
5627
5628    #[rstest]
5629    fn test_synthesize_trade_id_changes_with_fill_fields() {
5630        let mut msg = create_order_msg_for_event_test(
5631            OKXOrderStatus::Filled,
5632            "client-1",
5633            "venue-1",
5634            "50000.0",
5635            "0.001",
5636        );
5637        msg.fill_px = "50000.0".to_string();
5638        msg.fill_sz = "0.001".to_string();
5639        msg.fill_time = 1_746_947_317_500;
5640        msg.acc_fill_sz = Some("0.001".to_string());
5641
5642        let baseline = synthesize_trade_id(&msg);
5643
5644        msg.fill_sz = "0.002".to_string();
5645        let different_size = synthesize_trade_id(&msg);
5646        assert_ne!(baseline, different_size);
5647
5648        msg.fill_sz = "0.001".to_string();
5649        msg.fill_time = 1_746_947_317_999;
5650        let different_time = synthesize_trade_id(&msg);
5651        assert_ne!(baseline, different_time);
5652    }
5653
5654    #[rstest]
5655    fn test_empty_trade_id_fill_deduped_across_replays() {
5656        use crate::websocket::dispatch::WsDispatchState;
5657
5658        // Two identical fill messages with no venue trade_id - the dedup in
5659        // `WsDispatchState::check_and_insert_trade` must suppress the replay.
5660        // Regression lock for the empty-trade_id UUID fabrication bug: if
5661        // `synthesize_trade_id` drifts back to a non-deterministic id, the
5662        // second `check_and_insert_trade` would return false (not a dupe)
5663        // and this test fails.
5664        let mut msg = create_order_msg_for_event_test(
5665            OKXOrderStatus::Filled,
5666            "client-1",
5667            "venue-1",
5668            "50000.0",
5669            "0.001",
5670        );
5671        msg.trade_id = String::new();
5672        msg.fill_px = "50000.0".to_string();
5673        msg.fill_sz = "0.001".to_string();
5674        msg.fill_time = 1_746_947_317_500;
5675        msg.acc_fill_sz = Some("0.001".to_string());
5676
5677        let first_id = TradeId::new(synthesize_trade_id(&msg));
5678        let second_id = TradeId::new(synthesize_trade_id(&msg));
5679        assert_eq!(first_id, second_id, "synthesized id must survive replay");
5680
5681        let state = WsDispatchState::default();
5682        assert!(
5683            !state.check_and_insert_trade(first_id),
5684            "first insert is not a duplicate"
5685        );
5686        assert!(
5687            state.check_and_insert_trade(second_id),
5688            "replayed fill with empty trade_id must dedup"
5689        );
5690    }
5691
5692    #[rstest]
5693    fn test_parse_order_event_live_returns_accepted() {
5694        let instrument = create_stub_instrument();
5695        let msg = create_order_msg_for_event_test(
5696            OKXOrderStatus::Live,
5697            "test_client_123",
5698            "venue_456",
5699            "50000.0",
5700            "0.01",
5701        );
5702
5703        let client_order_id = ClientOrderId::new("test_client_123");
5704        let account_id = AccountId::new("OKX-001");
5705        let trader_id = TraderId::new("TRADER-001");
5706        let strategy_id = StrategyId::new("STRATEGY-001");
5707        let ts_init = UnixNanos::from(1000000000);
5708
5709        let result = parse_order_event(
5710            &msg,
5711            client_order_id,
5712            account_id,
5713            trader_id,
5714            strategy_id,
5715            &InstrumentAny::CryptoPerpetual(instrument),
5716            None,
5717            None,
5718            None,
5719            ts_init,
5720        );
5721
5722        assert!(result.is_ok());
5723        match result.unwrap() {
5724            ParsedOrderEvent::Accepted(accepted) => {
5725                assert_eq!(accepted.client_order_id, client_order_id);
5726                assert_eq!(accepted.venue_order_id, VenueOrderId::new("venue_456"));
5727                assert_eq!(accepted.trader_id, trader_id);
5728                assert_eq!(accepted.strategy_id, strategy_id);
5729            }
5730            other => panic!("Expected Accepted, was {other:?}"),
5731        }
5732    }
5733
5734    #[rstest]
5735    fn test_parse_spread_order_event_live_returns_accepted() {
5736        let instrument = create_stub_instrument();
5737        let msg = create_spread_order_msg_for_event_test(
5738            OKXOrderStatus::Live,
5739            "test_client_123",
5740            "venue_456",
5741            "1.0",
5742            "0.01",
5743        );
5744        let client_order_id = ClientOrderId::new("test_client_123");
5745        let account_id = AccountId::new("OKX-001");
5746        let trader_id = TraderId::new("TRADER-001");
5747        let strategy_id = StrategyId::new("STRATEGY-001");
5748        let ts_init = UnixNanos::from(1_000_000_000);
5749
5750        let result = parse_spread_order_event(
5751            &msg,
5752            client_order_id,
5753            account_id,
5754            trader_id,
5755            strategy_id,
5756            &InstrumentAny::CryptoPerpetual(instrument),
5757            None,
5758            None,
5759            ts_init,
5760        );
5761
5762        match result.unwrap() {
5763            ParsedOrderEvent::Accepted(accepted) => {
5764                assert_eq!(accepted.client_order_id, client_order_id);
5765                assert_eq!(accepted.venue_order_id, VenueOrderId::new("venue_456"));
5766                assert_eq!(accepted.account_id, account_id);
5767            }
5768            other => panic!("Expected Accepted, was {other:?}"),
5769        }
5770    }
5771
5772    #[rstest]
5773    fn test_parse_spread_order_event_canceled_returns_canceled() {
5774        let instrument = create_stub_instrument();
5775        let msg = create_spread_order_msg_for_event_test(
5776            OKXOrderStatus::Canceled,
5777            "test_client_123",
5778            "venue_456",
5779            "1.0",
5780            "0.01",
5781        );
5782        let client_order_id = ClientOrderId::new("test_client_123");
5783        let account_id = AccountId::new("OKX-001");
5784        let trader_id = TraderId::new("TRADER-001");
5785        let strategy_id = StrategyId::new("STRATEGY-001");
5786        let ts_init = UnixNanos::from(1_000_000_000);
5787
5788        let result = parse_spread_order_event(
5789            &msg,
5790            client_order_id,
5791            account_id,
5792            trader_id,
5793            strategy_id,
5794            &InstrumentAny::CryptoPerpetual(instrument),
5795            None,
5796            None,
5797            ts_init,
5798        );
5799
5800        match result.unwrap() {
5801            ParsedOrderEvent::Canceled(canceled) => {
5802                assert_eq!(canceled.client_order_id, client_order_id);
5803                assert_eq!(
5804                    canceled.venue_order_id,
5805                    Some(VenueOrderId::new("venue_456"))
5806                );
5807                assert_eq!(canceled.account_id, Some(account_id));
5808            }
5809            other => panic!("Expected Canceled, was {other:?}"),
5810        }
5811    }
5812
5813    #[rstest]
5814    fn test_parse_spread_order_event_filled_returns_fill() {
5815        let instrument = create_stub_instrument();
5816        let mut msg = create_spread_order_msg_for_event_test(
5817            OKXOrderStatus::Filled,
5818            "test_client_123",
5819            "venue_456",
5820            "1.0",
5821            "0.01",
5822        );
5823        msg.fill_sz = "0.01".to_string();
5824        msg.fill_px = "1.0".to_string();
5825        msg.trade_id = Ustr::from("trade_789");
5826        msg.acc_fill_sz = "0.01".to_string();
5827
5828        let client_order_id = ClientOrderId::new("test_client_123");
5829        let account_id = AccountId::new("OKX-001");
5830        let trader_id = TraderId::new("TRADER-001");
5831        let strategy_id = StrategyId::new("STRATEGY-001");
5832        let ts_init = UnixNanos::from(1_000_000_000);
5833
5834        let result = parse_spread_order_event(
5835            &msg,
5836            client_order_id,
5837            account_id,
5838            trader_id,
5839            strategy_id,
5840            &InstrumentAny::CryptoPerpetual(instrument),
5841            None,
5842            None,
5843            ts_init,
5844        );
5845
5846        let error = result.unwrap_err();
5847        assert!(error.to_string().contains("missing fee"));
5848    }
5849
5850    #[rstest]
5851    fn test_parse_spread_order_fill_report_uses_incremental_acc_fill_sz() {
5852        let instrument = create_stub_instrument();
5853        let mut msg = create_spread_order_msg_for_event_test(
5854            OKXOrderStatus::PartiallyFilled,
5855            "test_client_123",
5856            "venue_456",
5857            "1.0",
5858            "0.03",
5859        );
5860        msg.acc_fill_sz = "0.03".to_string();
5861        msg.fill_sz = String::new();
5862        msg.fill_px = String::new();
5863
5864        let error = parse_spread_order_fill_report(
5865            &msg,
5866            &InstrumentAny::CryptoPerpetual(instrument),
5867            AccountId::new("OKX-001"),
5868            Some(Quantity::from("0.01000000")),
5869            UnixNanos::from(1_000_000_000),
5870        )
5871        .unwrap_err();
5872
5873        assert!(error.to_string().contains("missing fee"));
5874        assert!(error.to_string().contains("sprd-orders updates omit fee"));
5875    }
5876
5877    #[rstest]
5878    fn test_parse_spread_order_fill_report_skips_duplicate_acc_fill_sz() {
5879        let instrument = create_stub_instrument();
5880        let mut msg = create_spread_order_msg_for_event_test(
5881            OKXOrderStatus::PartiallyFilled,
5882            "test_client_123",
5883            "venue_456",
5884            "1.0",
5885            "0.01",
5886        );
5887        msg.acc_fill_sz = "0.01".to_string();
5888        msg.fill_sz = String::new();
5889        msg.fill_px = String::new();
5890
5891        let result = parse_spread_order_fill_report(
5892            &msg,
5893            &InstrumentAny::CryptoPerpetual(instrument),
5894            AccountId::new("OKX-001"),
5895            Some(Quantity::from("0.01000000")),
5896            UnixNanos::from(1_000_000_000),
5897        )
5898        .unwrap();
5899
5900        assert!(result.is_none());
5901    }
5902
5903    #[rstest]
5904    fn test_parse_spread_order_fill_report_rejects_regressed_acc_fill_sz() {
5905        let instrument = create_stub_instrument();
5906        let mut msg = create_spread_order_msg_for_event_test(
5907            OKXOrderStatus::PartiallyFilled,
5908            "test_client_123",
5909            "venue_456",
5910            "1.0",
5911            "0.01",
5912        );
5913        msg.acc_fill_sz = "0.01".to_string();
5914        msg.fill_sz = String::new();
5915        msg.fill_px = String::new();
5916
5917        let error = parse_spread_order_fill_report(
5918            &msg,
5919            &InstrumentAny::CryptoPerpetual(instrument),
5920            AccountId::new("OKX-001"),
5921            Some(Quantity::from("0.03000000")),
5922            UnixNanos::from(1_000_000_000),
5923        )
5924        .unwrap_err();
5925
5926        assert!(
5927            error
5928                .to_string()
5929                .contains("Cumulative spread fill went backwards")
5930        );
5931    }
5932
5933    #[rstest]
5934    fn test_parse_order_event_live_with_price_change_returns_updated() {
5935        let instrument = create_stub_instrument();
5936        let msg = create_order_msg_for_event_test(
5937            OKXOrderStatus::Live,
5938            "test_client_123",
5939            "venue_456",
5940            "51000.0",
5941            "0.01",
5942        );
5943
5944        let client_order_id = ClientOrderId::new("test_client_123");
5945        let account_id = AccountId::new("OKX-001");
5946        let trader_id = TraderId::new("TRADER-001");
5947        let strategy_id = StrategyId::new("STRATEGY-001");
5948        let ts_init = UnixNanos::from(1000000000);
5949
5950        let previous_state = OrderStateSnapshot {
5951            venue_order_id: VenueOrderId::new("venue_456"),
5952            quantity: Quantity::from("0.01000000"),
5953            price: Some(Price::from("50000.00")),
5954        };
5955
5956        let result = parse_order_event(
5957            &msg,
5958            client_order_id,
5959            account_id,
5960            trader_id,
5961            strategy_id,
5962            &InstrumentAny::CryptoPerpetual(instrument),
5963            None,
5964            None,
5965            Some(&previous_state),
5966            ts_init,
5967        );
5968
5969        assert!(result.is_ok());
5970        match result.unwrap() {
5971            ParsedOrderEvent::Updated(updated) => {
5972                assert_eq!(updated.client_order_id, client_order_id);
5973                assert_eq!(updated.price, Some(Price::from("51000.00")));
5974            }
5975            other => panic!("Expected Updated, was {other:?}"),
5976        }
5977    }
5978
5979    #[rstest]
5980    fn test_parse_order_event_live_with_quantity_change_returns_updated() {
5981        let instrument = create_stub_instrument();
5982        let msg = create_order_msg_for_event_test(
5983            OKXOrderStatus::Live,
5984            "test_client_123",
5985            "venue_456",
5986            "50000.0",
5987            "0.02",
5988        );
5989
5990        let client_order_id = ClientOrderId::new("test_client_123");
5991        let account_id = AccountId::new("OKX-001");
5992        let trader_id = TraderId::new("TRADER-001");
5993        let strategy_id = StrategyId::new("STRATEGY-001");
5994        let ts_init = UnixNanos::from(1000000000);
5995        let previous_state = OrderStateSnapshot {
5996            venue_order_id: VenueOrderId::new("venue_456"),
5997            quantity: Quantity::from("0.01000000"),
5998            price: Some(Price::from("50000.00")),
5999        };
6000
6001        let result = parse_order_event(
6002            &msg,
6003            client_order_id,
6004            account_id,
6005            trader_id,
6006            strategy_id,
6007            &InstrumentAny::CryptoPerpetual(instrument),
6008            None,
6009            None,
6010            Some(&previous_state),
6011            ts_init,
6012        );
6013
6014        assert!(result.is_ok());
6015        match result.unwrap() {
6016            ParsedOrderEvent::Updated(updated) => {
6017                assert_eq!(updated.client_order_id, client_order_id);
6018                assert_eq!(updated.quantity, Quantity::from("0.02000000"));
6019            }
6020            other => panic!("Expected Updated, was {other:?}"),
6021        }
6022    }
6023
6024    #[rstest]
6025    fn test_parse_order_event_canceled_returns_canceled() {
6026        let instrument = create_stub_instrument();
6027        let msg = create_order_msg_for_event_test(
6028            OKXOrderStatus::Canceled,
6029            "test_client_123",
6030            "venue_456",
6031            "50000.0",
6032            "0.01",
6033        );
6034
6035        let client_order_id = ClientOrderId::new("test_client_123");
6036        let account_id = AccountId::new("OKX-001");
6037        let trader_id = TraderId::new("TRADER-001");
6038        let strategy_id = StrategyId::new("STRATEGY-001");
6039        let ts_init = UnixNanos::from(1000000000);
6040
6041        let result = parse_order_event(
6042            &msg,
6043            client_order_id,
6044            account_id,
6045            trader_id,
6046            strategy_id,
6047            &InstrumentAny::CryptoPerpetual(instrument),
6048            None,
6049            None,
6050            None,
6051            ts_init,
6052        );
6053
6054        assert!(result.is_ok());
6055        match result.unwrap() {
6056            ParsedOrderEvent::Canceled(canceled) => {
6057                assert_eq!(canceled.client_order_id, client_order_id);
6058                assert_eq!(
6059                    canceled.venue_order_id,
6060                    Some(VenueOrderId::new("venue_456"))
6061                );
6062            }
6063            other => panic!("Expected Canceled, was {other:?}"),
6064        }
6065    }
6066
6067    #[rstest]
6068    fn test_parse_order_event_canceled_with_expiry_reason_returns_expired() {
6069        let instrument = create_stub_instrument();
6070        let mut msg = create_order_msg_for_event_test(
6071            OKXOrderStatus::Canceled,
6072            "test_client_123",
6073            "venue_456",
6074            "50000.0",
6075            "0.01",
6076        );
6077        msg.cancel_source_reason = Some("GTD order expired".to_string());
6078
6079        let client_order_id = ClientOrderId::new("test_client_123");
6080        let account_id = AccountId::new("OKX-001");
6081        let trader_id = TraderId::new("TRADER-001");
6082        let strategy_id = StrategyId::new("STRATEGY-001");
6083        let ts_init = UnixNanos::from(1000000000);
6084
6085        let result = parse_order_event(
6086            &msg,
6087            client_order_id,
6088            account_id,
6089            trader_id,
6090            strategy_id,
6091            &InstrumentAny::CryptoPerpetual(instrument),
6092            None,
6093            None,
6094            None,
6095            ts_init,
6096        );
6097
6098        assert!(result.is_ok());
6099        match result.unwrap() {
6100            ParsedOrderEvent::Expired(expired) => {
6101                assert_eq!(expired.client_order_id, client_order_id);
6102                assert_eq!(expired.venue_order_id, Some(VenueOrderId::new("venue_456")));
6103            }
6104            other => panic!("Expected Expired, was {other:?}"),
6105        }
6106    }
6107
6108    #[rstest]
6109    fn test_parse_order_event_filled_with_fill_data_returns_fill() {
6110        let instrument = create_stub_instrument();
6111        let mut msg = create_order_msg_for_event_test(
6112            OKXOrderStatus::Filled,
6113            "test_client_123",
6114            "venue_456",
6115            "50000.0",
6116            "0.01",
6117        );
6118        msg.fill_sz = "0.01".to_string();
6119        msg.fill_px = "50000.0".to_string();
6120        msg.trade_id = "trade_789".to_string();
6121        msg.acc_fill_sz = Some("0.01".to_string());
6122
6123        let client_order_id = ClientOrderId::new("test_client_123");
6124        let account_id = AccountId::new("OKX-001");
6125        let trader_id = TraderId::new("TRADER-001");
6126        let strategy_id = StrategyId::new("STRATEGY-001");
6127        let ts_init = UnixNanos::from(1000000000);
6128
6129        let result = parse_order_event(
6130            &msg,
6131            client_order_id,
6132            account_id,
6133            trader_id,
6134            strategy_id,
6135            &InstrumentAny::CryptoPerpetual(instrument),
6136            None,
6137            None,
6138            None,
6139            ts_init,
6140        );
6141
6142        assert!(result.is_ok());
6143        match result.unwrap() {
6144            ParsedOrderEvent::Fill(fill) => {
6145                assert_eq!(fill.client_order_id, Some(client_order_id));
6146                assert_eq!(fill.venue_order_id, VenueOrderId::new("venue_456"));
6147                assert_eq!(fill.trade_id, TradeId::from("trade_789"));
6148            }
6149            other => panic!("Expected Fill, was {other:?}"),
6150        }
6151    }
6152
6153    #[rstest]
6154    fn test_is_order_expired_by_reason_gtd_in_reason() {
6155        let mut msg =
6156            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6157        msg.cancel_source_reason = Some("GTD order expired".to_string());
6158        assert!(is_order_expired_by_reason(&msg));
6159    }
6160
6161    #[rstest]
6162    fn test_is_order_expired_by_reason_timeout_in_reason() {
6163        let mut msg =
6164            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6165        msg.cancel_source_reason = Some("Order timeout".to_string());
6166        assert!(is_order_expired_by_reason(&msg));
6167    }
6168
6169    #[rstest]
6170    fn test_is_order_expired_by_reason_expir_in_reason() {
6171        let mut msg =
6172            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6173        msg.cancel_source_reason = Some("Expiration reached".to_string());
6174        assert!(is_order_expired_by_reason(&msg));
6175    }
6176
6177    #[rstest]
6178    fn test_is_order_expired_by_reason_source_code_5() {
6179        let mut msg =
6180            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6181        msg.cancel_source = Some("5".to_string());
6182        assert!(is_order_expired_by_reason(&msg));
6183    }
6184
6185    #[rstest]
6186    fn test_is_order_expired_by_reason_source_time_expired() {
6187        let mut msg =
6188            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6189        msg.cancel_source = Some("time_expired".to_string());
6190        assert!(is_order_expired_by_reason(&msg));
6191    }
6192
6193    #[rstest]
6194    fn test_is_order_expired_by_reason_false_for_user_cancel() {
6195        let mut msg =
6196            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6197        msg.cancel_source_reason = Some("User canceled".to_string());
6198        msg.cancel_source = Some("1".to_string());
6199        assert!(!is_order_expired_by_reason(&msg));
6200    }
6201
6202    #[rstest]
6203    fn test_is_order_expired_by_reason_false_when_no_reason() {
6204        let msg =
6205            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6206        assert!(!is_order_expired_by_reason(&msg));
6207    }
6208
6209    fn fresh_cancel_source_seen() -> Mutex<AHashSet<String>> {
6210        Mutex::new(AHashSet::new())
6211    }
6212
6213    #[rstest]
6214    fn test_log_unknown_cancel_source_records_first_observation() {
6215        let mut msg =
6216            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6217        msg.cancel_source = Some("99".to_string());
6218        msg.cancel_source_reason = Some("Unknown reason".to_string());
6219
6220        let seen = fresh_cancel_source_seen();
6221        assert!(log_unknown_cancel_source_inner(&msg, &seen, 8));
6222        assert_eq!(seen.lock().len(), 1);
6223    }
6224
6225    #[rstest]
6226    fn test_log_unknown_cancel_source_dedups_repeat_pair() {
6227        let mut msg =
6228            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6229        msg.cancel_source = Some("99".to_string());
6230        msg.cancel_source_reason = Some("Unknown reason".to_string());
6231
6232        let seen = fresh_cancel_source_seen();
6233        assert!(log_unknown_cancel_source_inner(&msg, &seen, 8));
6234        assert!(!log_unknown_cancel_source_inner(&msg, &seen, 8));
6235        assert_eq!(seen.lock().len(), 1);
6236    }
6237
6238    #[rstest]
6239    #[case::post_only("31")]
6240    #[case::known_expired("5")]
6241    #[case::sentinel_time("time_expired")]
6242    #[case::sentinel_gtd("gtd_expired")]
6243    fn test_log_unknown_cancel_source_skips_known_sources(#[case] source: &str) {
6244        let mut msg =
6245            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6246        msg.cancel_source = Some(source.to_string());
6247
6248        let seen = fresh_cancel_source_seen();
6249        assert!(!log_unknown_cancel_source_inner(&msg, &seen, 8));
6250        assert!(seen.lock().is_empty());
6251    }
6252
6253    #[rstest]
6254    fn test_log_unknown_cancel_source_skips_when_source_and_reason_empty() {
6255        let msg =
6256            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6257        let seen = fresh_cancel_source_seen();
6258        assert!(!log_unknown_cancel_source_inner(&msg, &seen, 8));
6259        assert!(seen.lock().is_empty());
6260    }
6261
6262    #[rstest]
6263    fn test_log_unknown_cancel_source_respects_capacity_cap() {
6264        let cap = 4;
6265        let seen = fresh_cancel_source_seen();
6266
6267        for i in 0..cap {
6268            let mut msg = create_order_msg_for_event_test(
6269                OKXOrderStatus::Canceled,
6270                "test",
6271                "123",
6272                "100",
6273                "1",
6274            );
6275            msg.cancel_source = Some(format!("novel_{i}"));
6276            assert!(log_unknown_cancel_source_inner(&msg, &seen, cap));
6277        }
6278
6279        let mut overflow =
6280            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
6281        overflow.cancel_source = Some("novel_overflow".to_string());
6282        assert!(!log_unknown_cancel_source_inner(&overflow, &seen, cap));
6283        assert_eq!(seen.lock().len(), cap);
6284    }
6285
6286    // Regression test: PartiallyFilled order with price change should emit Updated, not StatusOnly
6287    #[rstest]
6288    fn test_parse_order_event_partially_filled_with_price_change_returns_updated() {
6289        let instrument = create_stub_instrument();
6290        let msg = create_order_msg_for_event_test(
6291            OKXOrderStatus::PartiallyFilled,
6292            "test_client_123",
6293            "venue_456",
6294            "51000.0",
6295            "0.01",
6296        );
6297
6298        let client_order_id = ClientOrderId::new("test_client_123");
6299        let account_id = AccountId::new("OKX-001");
6300        let trader_id = TraderId::new("TRADER-001");
6301        let strategy_id = StrategyId::new("STRATEGY-001");
6302        let ts_init = UnixNanos::from(1000000000);
6303
6304        let previous_state = OrderStateSnapshot {
6305            venue_order_id: VenueOrderId::new("venue_456"),
6306            quantity: Quantity::from("0.01000000"),
6307            price: Some(Price::from("50000.00")),
6308        };
6309
6310        let result = parse_order_event(
6311            &msg,
6312            client_order_id,
6313            account_id,
6314            trader_id,
6315            strategy_id,
6316            &InstrumentAny::CryptoPerpetual(instrument),
6317            None,
6318            None,
6319            Some(&previous_state),
6320            ts_init,
6321        );
6322
6323        assert!(result.is_ok());
6324        match result.unwrap() {
6325            ParsedOrderEvent::Updated(updated) => {
6326                assert_eq!(updated.client_order_id, client_order_id);
6327                assert_eq!(updated.price, Some(Price::from("51000.00")));
6328            }
6329            other => {
6330                panic!("Expected Updated for PartiallyFilled with price change, was {other:?}")
6331            }
6332        }
6333    }
6334
6335    #[rstest]
6336    fn test_is_order_updated_price_change() {
6337        let instrument = create_stub_instrument();
6338        let msg = create_order_msg_for_event_test(
6339            OKXOrderStatus::Live,
6340            "test",
6341            "venue_123",
6342            "51000.0",
6343            "0.01",
6344        );
6345
6346        let previous = OrderStateSnapshot {
6347            venue_order_id: VenueOrderId::new("venue_123"),
6348            quantity: Quantity::from("0.01000000"),
6349            price: Some(Price::from("50000.00")),
6350        };
6351
6352        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
6353        assert!(result.is_ok());
6354        assert!(result.unwrap());
6355    }
6356
6357    #[rstest]
6358    fn test_is_order_updated_quantity_change() {
6359        let instrument = create_stub_instrument();
6360        let msg = create_order_msg_for_event_test(
6361            OKXOrderStatus::Live,
6362            "test",
6363            "venue_123",
6364            "50000.0",
6365            "0.02", // New quantity
6366        );
6367
6368        let previous = OrderStateSnapshot {
6369            venue_order_id: VenueOrderId::new("venue_123"),
6370            quantity: Quantity::from("0.01000000"), // Old quantity
6371            price: Some(Price::from("50000.00")),
6372        };
6373
6374        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
6375        assert!(result.is_ok());
6376        assert!(result.unwrap());
6377    }
6378
6379    #[rstest]
6380    fn test_is_order_updated_venue_id_change() {
6381        let instrument = create_stub_instrument();
6382        let msg = create_order_msg_for_event_test(
6383            OKXOrderStatus::Live,
6384            "test",
6385            "venue_456", // New venue ID
6386            "50000.0",
6387            "0.01",
6388        );
6389
6390        let previous = OrderStateSnapshot {
6391            venue_order_id: VenueOrderId::new("venue_123"), // Old venue ID
6392            quantity: Quantity::from("0.01000000"),
6393            price: Some(Price::from("50000.00")),
6394        };
6395
6396        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
6397        assert!(result.is_ok());
6398        assert!(result.unwrap());
6399    }
6400
6401    #[rstest]
6402    fn test_is_order_updated_no_change() {
6403        let instrument = create_stub_instrument();
6404        let msg = create_order_msg_for_event_test(
6405            OKXOrderStatus::Live,
6406            "test",
6407            "venue_123",
6408            "50000.0",
6409            "0.01",
6410        );
6411
6412        let previous = OrderStateSnapshot {
6413            venue_order_id: VenueOrderId::new("venue_123"),
6414            quantity: Quantity::from("0.01000000"),
6415            price: Some(Price::from("50000.00")),
6416        };
6417
6418        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
6419        assert!(result.is_ok());
6420        assert!(!result.unwrap());
6421    }
6422
6423    #[rstest]
6424    fn test_parse_order_status_report_ts_last_and_ts_init_ordering() {
6425        let instrument = create_stub_instrument();
6426        let inst = InstrumentAny::CryptoPerpetual(instrument);
6427        let account_id = AccountId::new("OKX-001");
6428        let ts_init = UnixNanos::from(999_000_000_000u64);
6429
6430        let msg = OKXOrderMsg {
6431            acc_fill_sz: Some("0".to_string()),
6432            algo_id: None,
6433            avg_px: String::new(),
6434            c_time: 1706000000000, // ~2024-01-23 in ms
6435            cancel_source: None,
6436            cancel_source_reason: None,
6437            category: OKXOrderCategory::Normal,
6438            ccy: Ustr::from("USDT"),
6439            cl_ord_id: "test_ts_order".to_string(),
6440            algo_cl_ord_id: None,
6441            attach_algo_cl_ord_id: None,
6442            attach_algo_ords: Vec::new(),
6443            outcome: None,
6444            fee: None,
6445            fee_ccy: Ustr::from("USDT"),
6446            fill_fee: None,
6447            fill_fee_ccy: None,
6448            fill_mark_px: None,
6449            fill_mark_vol: None,
6450            fill_px_vol: None,
6451            fill_px_usd: None,
6452            fill_fwd_px: None,
6453            fill_notional_usd: None,
6454            fill_pnl: None,
6455            fill_px: String::new(),
6456            fill_sz: String::new(),
6457            fill_time: 0,
6458            inst_id: Ustr::from("BTC-USDT-SWAP"),
6459            inst_type: OKXInstrumentType::Swap,
6460            is_tp_limit: None,
6461            lever: String::new(),
6462            linked_algo_ord: None,
6463            notional_usd: None,
6464            ord_id: Ustr::from("123456"),
6465            ord_type: OKXOrderType::Limit,
6466            pnl: String::new(),
6467            pos_side: OKXPositionSide::Long,
6468            px: "50000.00".to_string(),
6469            px_type: OKXPriceType::None,
6470            px_usd: None,
6471            px_vol: None,
6472            quick_mgn_type: OKXQuickMarginType::None,
6473            rebate: None,
6474            rebate_ccy: None,
6475            reduce_only: "false".to_string(),
6476            side: OKXSide::Buy,
6477            sl_ord_px: None,
6478            sl_trigger_px: None,
6479            sl_trigger_px_type: None,
6480            source: None,
6481            state: OKXOrderStatus::Live,
6482            stp_id: None,
6483            stp_mode: OKXSelfTradePreventionMode::None,
6484            exec_type: OKXExecType::Taker,
6485            sz: "0.01".to_string(),
6486            tag: None,
6487            td_mode: OKXTradeMode::Cross,
6488            tgt_ccy: None,
6489            tp_ord_px: None,
6490            tp_trigger_px: None,
6491            tp_trigger_px_type: None,
6492            trade_id: String::new(),
6493            u_time: 1706000001000, // 1 second later in ms
6494            amend_result: None,
6495            req_id: None,
6496            code: None,
6497            msg: None,
6498        };
6499
6500        let report = parse_order_status_report(&msg, &inst, account_id, ts_init).unwrap();
6501
6502        assert_eq!(
6503            report.ts_accepted,
6504            UnixNanos::from(1706000000000u64 * 1_000_000)
6505        );
6506        assert_eq!(
6507            report.ts_last,
6508            UnixNanos::from(1706000001000u64 * 1_000_000)
6509        );
6510        assert_eq!(report.ts_init, ts_init);
6511    }
6512
6513    #[rstest]
6514    fn test_parse_order_status_report_preserves_attached_tp_sl_child_ids() {
6515        let instrument = create_stub_instrument();
6516        let inst = InstrumentAny::CryptoPerpetual(instrument);
6517        let account_id = AccountId::new("OKX-001");
6518        let ts_init = UnixNanos::default();
6519
6520        let msg = OKXOrderMsg {
6521            acc_fill_sz: Some("0".to_string()),
6522            algo_id: None,
6523            avg_px: String::new(),
6524            c_time: 1706000000000,
6525            cancel_source: None,
6526            cancel_source_reason: None,
6527            category: OKXOrderCategory::Normal,
6528            ccy: Ustr::from("USDT"),
6529            cl_ord_id: "O-attached-entry".to_string(),
6530            algo_cl_ord_id: None,
6531            attach_algo_cl_ord_id: Some("O-attached-sl".to_string()),
6532            attach_algo_ords: vec![
6533                OKXAttachedAlgoOrd {
6534                    attach_algo_id: "algo-sl".to_string(),
6535                    attach_algo_cl_ord_id: "O-attached-sl".to_string(),
6536                    sl_trigger_px: "1500".to_string(),
6537                    sl_ord_px: "-1".to_string(),
6538                    sl_trigger_px_type: Some(OKXTriggerType::Last),
6539                    tp_trigger_px: String::new(),
6540                    tp_ord_px: String::new(),
6541                    tp_trigger_px_type: None,
6542                    callback_ratio: String::new(),
6543                    callback_spread: String::new(),
6544                    active_px: String::new(),
6545                },
6546                OKXAttachedAlgoOrd {
6547                    attach_algo_id: "algo-tp".to_string(),
6548                    attach_algo_cl_ord_id: "O-attached-tp".to_string(),
6549                    sl_trigger_px: String::new(),
6550                    sl_ord_px: String::new(),
6551                    sl_trigger_px_type: None,
6552                    tp_trigger_px: "2500".to_string(),
6553                    tp_ord_px: "-1".to_string(),
6554                    tp_trigger_px_type: Some(OKXTriggerType::Last),
6555                    callback_ratio: String::new(),
6556                    callback_spread: String::new(),
6557                    active_px: String::new(),
6558                },
6559            ],
6560            outcome: None,
6561            fee: None,
6562            fee_ccy: Ustr::from("USDT"),
6563            fill_fee: None,
6564            fill_fee_ccy: None,
6565            fill_mark_px: None,
6566            fill_mark_vol: None,
6567            fill_px_vol: None,
6568            fill_px_usd: None,
6569            fill_fwd_px: None,
6570            fill_notional_usd: None,
6571            fill_pnl: None,
6572            fill_px: String::new(),
6573            fill_sz: String::new(),
6574            fill_time: 0,
6575            inst_id: Ustr::from("BTC-USDT-SWAP"),
6576            inst_type: OKXInstrumentType::Swap,
6577            is_tp_limit: None,
6578            lever: String::new(),
6579            linked_algo_ord: None,
6580            notional_usd: None,
6581            ord_id: Ustr::from("123456"),
6582            ord_type: OKXOrderType::Limit,
6583            pnl: String::new(),
6584            pos_side: OKXPositionSide::Long,
6585            px: "2000.00".to_string(),
6586            px_type: OKXPriceType::None,
6587            px_usd: None,
6588            px_vol: None,
6589            quick_mgn_type: OKXQuickMarginType::None,
6590            rebate: None,
6591            rebate_ccy: None,
6592            reduce_only: "false".to_string(),
6593            side: OKXSide::Buy,
6594            sl_ord_px: None,
6595            sl_trigger_px: None,
6596            sl_trigger_px_type: None,
6597            source: None,
6598            state: OKXOrderStatus::Live,
6599            stp_id: None,
6600            stp_mode: OKXSelfTradePreventionMode::None,
6601            exec_type: OKXExecType::Taker,
6602            sz: "0.01".to_string(),
6603            tag: None,
6604            td_mode: OKXTradeMode::Cross,
6605            tgt_ccy: None,
6606            tp_ord_px: None,
6607            tp_trigger_px: None,
6608            tp_trigger_px_type: None,
6609            trade_id: String::new(),
6610            u_time: 1706000001000,
6611            amend_result: None,
6612            req_id: None,
6613            code: None,
6614            msg: None,
6615        };
6616
6617        let report = parse_order_status_report(&msg, &inst, account_id, ts_init).unwrap();
6618        let linked_order_ids = report
6619            .linked_order_ids
6620            .expect("expected linked child order ids");
6621
6622        assert_eq!(linked_order_ids.len(), 2);
6623        assert!(linked_order_ids.contains(&ClientOrderId::from("O-attached-sl")));
6624        assert!(linked_order_ids.contains(&ClientOrderId::from("O-attached-tp")));
6625    }
6626
6627    #[rstest]
6628    fn test_parse_algo_order_timestamps_converted_from_ms_to_ns() {
6629        let instrument = create_stub_instrument();
6630        let inst = InstrumentAny::CryptoPerpetual(instrument);
6631        let account_id = AccountId::new("OKX-001");
6632        let ts_init = UnixNanos::from(999_000_000_000u64);
6633
6634        let msg = OKXAlgoOrderMsg {
6635            algo_id: "algo_1".to_string(),
6636            algo_cl_ord_id: "algo_cl_1".to_string(),
6637            cl_ord_id: String::new(),
6638            ord_id: String::new(),
6639            ord_id_list: Vec::new(),
6640            inst_id: Ustr::from("BTC-USDT-SWAP"),
6641            inst_type: OKXInstrumentType::Swap,
6642            ord_type: OKXAlgoOrderType::Trigger,
6643            state: OKXAlgoOrderStatus::Live,
6644            side: OKXSide::Buy,
6645            pos_side: OKXPositionSide::Long,
6646            sz: "0.01".to_string(),
6647            trigger_px: "45000.00".to_string(),
6648            trigger_px_type: OKXTriggerType::Last,
6649            sl_trigger_px: String::new(),
6650            sl_ord_px: String::new(),
6651            sl_trigger_px_type: OKXTriggerType::None,
6652            tp_trigger_px: String::new(),
6653            tp_ord_px: String::new(),
6654            tp_trigger_px_type: OKXTriggerType::None,
6655            ord_px: "-1".to_string(),
6656            td_mode: OKXTradeMode::Cross,
6657            lever: String::new(),
6658            reduce_only: "false".to_string(),
6659            close_fraction: String::new(),
6660            actual_px: String::new(),
6661            actual_sz: String::new(),
6662            notional_usd: String::new(),
6663            c_time: 1706000000000,
6664            u_time: 1706000001000,
6665            trigger_time: String::new(),
6666            fail_code: String::new(),
6667            tag: String::new(),
6668            callback_ratio: String::new(),
6669            callback_spread: String::new(),
6670            active_px: String::new(),
6671            ccy: None,
6672            tgt_ccy: None,
6673            fee: None,
6674            fee_ccy: None,
6675            advance_ord_type: None,
6676        };
6677
6678        let report = parse_algo_order_status_report(&msg, &inst, account_id, ts_init).unwrap();
6679
6680        let expected_accepted_ns = 1706000000000u64 * 1_000_000;
6681        let expected_last_ns = 1706000001000u64 * 1_000_000;
6682        assert_eq!(report.ts_accepted, UnixNanos::from(expected_accepted_ns));
6683        assert_eq!(report.ts_last, UnixNanos::from(expected_last_ns));
6684        assert_eq!(report.ts_init, ts_init);
6685    }
6686
6687    fn stub_algo_order_msg(ord_type: OKXAlgoOrderType) -> OKXAlgoOrderMsg {
6688        OKXAlgoOrderMsg {
6689            algo_id: "algo_1".to_string(),
6690            algo_cl_ord_id: "algo_cl_1".to_string(),
6691            cl_ord_id: String::new(),
6692            ord_id: String::new(),
6693            ord_id_list: Vec::new(),
6694            inst_id: Ustr::from("BTC-USDT-SWAP"),
6695            inst_type: OKXInstrumentType::Swap,
6696            ord_type,
6697            state: OKXAlgoOrderStatus::Live,
6698            side: OKXSide::Sell,
6699            pos_side: OKXPositionSide::Long,
6700            sz: "0.01".to_string(),
6701            trigger_px: "95000.00".to_string(),
6702            trigger_px_type: OKXTriggerType::Last,
6703            sl_trigger_px: String::new(),
6704            sl_ord_px: String::new(),
6705            sl_trigger_px_type: OKXTriggerType::None,
6706            tp_trigger_px: String::new(),
6707            tp_ord_px: String::new(),
6708            tp_trigger_px_type: OKXTriggerType::None,
6709            ord_px: "-1".to_string(),
6710            td_mode: OKXTradeMode::Cross,
6711            lever: String::new(),
6712            reduce_only: "false".to_string(),
6713            close_fraction: String::new(),
6714            actual_px: String::new(),
6715            actual_sz: String::new(),
6716            notional_usd: String::new(),
6717            c_time: 1706000000000,
6718            u_time: 1706000001000,
6719            trigger_time: String::new(),
6720            fail_code: String::new(),
6721            tag: String::new(),
6722            callback_ratio: String::new(),
6723            callback_spread: String::new(),
6724            active_px: String::new(),
6725            ccy: None,
6726            tgt_ccy: None,
6727            fee: None,
6728            fee_ccy: None,
6729            advance_ord_type: None,
6730        }
6731    }
6732
6733    #[rstest]
6734    fn test_parse_algo_order_trailing_stop_with_callback_ratio() {
6735        let instrument = create_stub_instrument();
6736        let inst = InstrumentAny::CryptoPerpetual(instrument);
6737        let account_id = AccountId::new("OKX-001");
6738
6739        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::MoveOrderStop);
6740        msg.callback_ratio = "0.01".to_string(); // 1% = 100 basis points
6741
6742        let report =
6743            parse_algo_order_status_report(&msg, &inst, account_id, UnixNanos::default()).unwrap();
6744
6745        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
6746        assert_eq!(report.trailing_offset, Some(dec!(100)));
6747        assert_eq!(
6748            report.trailing_offset_type,
6749            Some(TrailingOffsetType::BasisPoints),
6750        );
6751        assert_eq!(report.trigger_price, Some(Price::from("95000.00")));
6752    }
6753
6754    #[rstest]
6755    fn test_parse_algo_order_trailing_stop_captures_activation_price() {
6756        let instrument = create_stub_instrument();
6757        let inst = InstrumentAny::CryptoPerpetual(instrument);
6758        let account_id = AccountId::new("OKX-001");
6759
6760        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::MoveOrderStop);
6761        msg.callback_ratio = "0.01".to_string();
6762        msg.active_px = "94000.5".to_string();
6763
6764        let report =
6765            parse_algo_order_status_report(&msg, &inst, account_id, UnixNanos::default()).unwrap();
6766
6767        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
6768        assert_eq!(report.activation_price, Some(Price::from("94000.50")));
6769    }
6770
6771    #[rstest]
6772    fn test_parse_algo_order_trailing_stop_with_callback_spread() {
6773        let instrument = create_stub_instrument();
6774        let inst = InstrumentAny::CryptoPerpetual(instrument);
6775        let account_id = AccountId::new("OKX-001");
6776
6777        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::MoveOrderStop);
6778        msg.callback_spread = "50.5".to_string();
6779
6780        let report =
6781            parse_algo_order_status_report(&msg, &inst, account_id, UnixNanos::default()).unwrap();
6782
6783        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
6784        assert_eq!(report.trailing_offset, Some(dec!(50.5)));
6785        assert_eq!(report.trailing_offset_type, Some(TrailingOffsetType::Price),);
6786    }
6787
6788    #[rstest]
6789    fn test_parse_algo_order_unsupported_type_skipped() {
6790        let instrument = create_stub_instrument();
6791        let account_id = AccountId::new("OKX-001");
6792        let mut instruments = AHashMap::new();
6793        instruments.insert(
6794            Ustr::from("BTC-USDT-SWAP"),
6795            InstrumentAny::CryptoPerpetual(instrument),
6796        );
6797
6798        let msg = stub_algo_order_msg(OKXAlgoOrderType::Iceberg);
6799
6800        let result = parse_algo_order_msg(&msg, account_id, &instruments, UnixNanos::default());
6801
6802        assert!(result.unwrap().is_none());
6803    }
6804
6805    #[rstest]
6806    fn test_parse_algo_order_chase_type_skipped() {
6807        let instrument = create_stub_instrument();
6808        let account_id = AccountId::new("OKX-001");
6809        let mut instruments = AHashMap::new();
6810        instruments.insert(
6811            Ustr::from("BTC-USDT-SWAP"),
6812            InstrumentAny::CryptoPerpetual(instrument),
6813        );
6814
6815        let msg = stub_algo_order_msg(OKXAlgoOrderType::Chase);
6816
6817        let result = parse_algo_order_msg(&msg, account_id, &instruments, UnixNanos::default());
6818
6819        assert!(result.unwrap().is_none());
6820    }
6821
6822    #[rstest]
6823    fn test_parse_algo_order_unknown_type_skipped() {
6824        let instrument = create_stub_instrument();
6825        let account_id = AccountId::new("OKX-001");
6826        let mut instruments = AHashMap::new();
6827        instruments.insert(
6828            Ustr::from("BTC-USDT-SWAP"),
6829            InstrumentAny::CryptoPerpetual(instrument),
6830        );
6831
6832        let msg = stub_algo_order_msg(OKXAlgoOrderType::Other);
6833
6834        let result = parse_algo_order_msg(&msg, account_id, &instruments, UnixNanos::default());
6835
6836        assert!(result.unwrap().is_none());
6837    }
6838
6839    #[rstest]
6840    fn test_parse_algo_order_unknown_state_skipped() {
6841        let instrument = create_stub_instrument();
6842        let account_id = AccountId::new("OKX-001");
6843        let mut instruments = AHashMap::new();
6844        instruments.insert(
6845            Ustr::from("BTC-USDT-SWAP"),
6846            InstrumentAny::CryptoPerpetual(instrument),
6847        );
6848
6849        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::Trigger);
6850        msg.state = OKXAlgoOrderStatus::Unknown;
6851
6852        let result = parse_algo_order_msg(&msg, account_id, &instruments, UnixNanos::default());
6853
6854        assert!(result.unwrap().is_none());
6855    }
6856
6857    #[rstest]
6858    fn test_deserialize_algo_order_states_message() {
6859        let json_data = load_test_json("ws_orders_algo_states.json");
6860        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
6861        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
6862
6863        assert_eq!(data.len(), 6);
6864        assert_eq!(data[0].state, OKXAlgoOrderStatus::Effective);
6865        assert_eq!(data[1].state, OKXAlgoOrderStatus::PartiallyEffective);
6866        assert_eq!(data[2].state, OKXAlgoOrderStatus::Pause);
6867        assert_eq!(data[3].state, OKXAlgoOrderStatus::OrderFailed);
6868        assert_eq!(data[4].state, OKXAlgoOrderStatus::PartiallyFailed);
6869        assert_eq!(data[5].ord_type, OKXAlgoOrderType::Chase);
6870    }
6871
6872    #[rstest]
6873    fn test_parse_algo_order_states_map_to_nautilus_status() {
6874        let json_data = load_test_json("ws_orders_algo_states.json");
6875        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
6876        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
6877
6878        let account_id = AccountId::new("OKX-001");
6879        let mut instruments = AHashMap::new();
6880        instruments.insert(
6881            Ustr::from("BTC-USDT-SWAP"),
6882            InstrumentAny::CryptoPerpetual(create_stub_instrument()),
6883        );
6884
6885        let expected = [
6886            (0_usize, OrderStatus::Triggered), // effective
6887            (1, OrderStatus::Triggered),       // partially_effective
6888            (2, OrderStatus::Accepted),        // pause
6889            (3, OrderStatus::Rejected),        // order_failed
6890            (4, OrderStatus::Rejected),        // partially_failed
6891        ];
6892
6893        for (idx, expected_status) in expected {
6894            let report =
6895                parse_algo_order_msg(&data[idx], account_id, &instruments, UnixNanos::default())
6896                    .unwrap()
6897                    .unwrap_or_else(|| panic!("Expected report for fixture index {idx}"));
6898
6899            match report {
6900                ExecutionReport::Order(report) => {
6901                    assert_eq!(report.order_status, expected_status);
6902                }
6903                other => panic!("Expected Order report for fixture index {idx}, was {other:?}"),
6904            }
6905        }
6906
6907        // Chase orders are unsupported and skipped; their triggered child orders
6908        // still arrive on the regular orders channel
6909        let chase =
6910            parse_algo_order_msg(&data[5], account_id, &instruments, UnixNanos::default()).unwrap();
6911        assert!(chase.is_none());
6912    }
6913
6914    #[rstest]
6915    fn test_parse_algo_order_missing_trigger_px_type_defaults() {
6916        let instrument = create_stub_instrument();
6917        let inst = InstrumentAny::CryptoPerpetual(instrument);
6918        let account_id = AccountId::new("OKX-001");
6919
6920        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::MoveOrderStop);
6921        msg.trigger_px_type = OKXTriggerType::None;
6922        msg.callback_ratio = "0.005".to_string();
6923
6924        let report =
6925            parse_algo_order_status_report(&msg, &inst, account_id, UnixNanos::default()).unwrap();
6926
6927        assert_eq!(report.trigger_type, Some(TriggerType::Default));
6928        assert_eq!(report.order_type, OrderType::TrailingStopMarket);
6929    }
6930
6931    #[rstest]
6932    fn test_parse_algo_order_close_fraction_stop_market_without_sz() {
6933        let instrument = create_stub_instrument();
6934        let inst = InstrumentAny::CryptoPerpetual(instrument);
6935        let account_id = AccountId::new("OKX-001");
6936
6937        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::Conditional);
6938        msg.sz = String::new();
6939        msg.trigger_px = String::new();
6940        msg.trigger_px_type = OKXTriggerType::None;
6941        msg.ord_px = String::new();
6942        msg.sl_trigger_px = "50000".to_string();
6943        msg.sl_ord_px = "-1".to_string();
6944        msg.sl_trigger_px_type = OKXTriggerType::Last;
6945        msg.close_fraction = "1".to_string();
6946        msg.reduce_only = "true".to_string();
6947
6948        let report =
6949            parse_algo_order_status_report(&msg, &inst, account_id, UnixNanos::default()).unwrap();
6950
6951        assert_eq!(report.order_type, OrderType::StopMarket);
6952        assert_eq!(report.trigger_price, Some(Price::from("50000.00")));
6953        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
6954        assert_eq!(report.price, None);
6955        assert_eq!(report.quantity, Quantity::zero(inst.size_precision()));
6956        assert!(report.reduce_only);
6957    }
6958
6959    #[rstest]
6960    fn test_parse_algo_order_close_fraction_market_if_touched_without_sz() {
6961        let instrument = create_stub_instrument();
6962        let inst = InstrumentAny::CryptoPerpetual(instrument);
6963        let account_id = AccountId::new("OKX-001");
6964
6965        let mut msg = stub_algo_order_msg(OKXAlgoOrderType::Conditional);
6966        msg.sz = String::new();
6967        msg.trigger_px = String::new();
6968        msg.trigger_px_type = OKXTriggerType::None;
6969        msg.ord_px = String::new();
6970        msg.sl_trigger_px = String::new();
6971        msg.sl_ord_px = String::new();
6972        msg.tp_trigger_px = "50000".to_string();
6973        msg.tp_ord_px = "-1".to_string();
6974        msg.tp_trigger_px_type = OKXTriggerType::Last;
6975        msg.close_fraction = "1".to_string();
6976        msg.reduce_only = "true".to_string();
6977        msg.side = OKXSide::Buy;
6978
6979        let report =
6980            parse_algo_order_status_report(&msg, &inst, account_id, UnixNanos::default()).unwrap();
6981
6982        assert_eq!(report.order_type, OrderType::MarketIfTouched);
6983        assert_eq!(report.trigger_price, Some(Price::from("50000.00")));
6984        assert_eq!(report.trigger_type, Some(TriggerType::LastPrice));
6985        assert_eq!(report.price, None);
6986        assert_eq!(report.quantity, Quantity::zero(inst.size_precision()));
6987        assert!(report.reduce_only);
6988    }
6989
6990    fn stub_book_entry(price: &str, size: &str) -> OrderBookEntry {
6991        OrderBookEntry {
6992            price: price.to_string(),
6993            size: size.to_string(),
6994            liquidated_orders_count: "0".to_string(),
6995            orders_count: "1".to_string(),
6996        }
6997    }
6998
6999    fn stub_book_msg(bids: Vec<OrderBookEntry>, asks: Vec<OrderBookEntry>) -> OKXBookMsg {
7000        OKXBookMsg {
7001            bids,
7002            asks,
7003            ts: 1706000000000,
7004            seq_id: 1,
7005            prev_seq_id: Some(0),
7006            checksum: None,
7007        }
7008    }
7009
7010    #[rstest]
7011    fn test_parse_quote_msg_empty_bids_returns_error() {
7012        let msg = stub_book_msg(vec![], vec![stub_book_entry("50000.00", "1.0")]);
7013
7014        let result = parse_quote_msg(
7015            &msg,
7016            InstrumentId::from("BTC-USDT.OKX"),
7017            2,
7018            8,
7019            UnixNanos::default(),
7020        );
7021        assert!(result.is_err());
7022        assert!(result.unwrap_err().to_string().contains("Empty bids"));
7023    }
7024
7025    #[rstest]
7026    fn test_parse_quote_msg_empty_asks_returns_error() {
7027        let msg = stub_book_msg(vec![stub_book_entry("50000.00", "1.0")], vec![]);
7028
7029        let result = parse_quote_msg(
7030            &msg,
7031            InstrumentId::from("BTC-USDT.OKX"),
7032            2,
7033            8,
7034            UnixNanos::default(),
7035        );
7036        assert!(result.is_err());
7037        assert!(result.unwrap_err().to_string().contains("Empty asks"));
7038    }
7039
7040    #[rstest]
7041    fn test_quote_cache_complete_bbo_tbt_message() {
7042        use nautilus_common::cache::quote::QuoteCache;
7043
7044        let mut cache = QuoteCache::new();
7045        let instrument_id = InstrumentId::from("BTC-USD-260327-75000-C.OKX");
7046        let msg = stub_book_msg(
7047            vec![stub_book_entry("0.0035", "100")],
7048            vec![stub_book_entry("0.0040", "200")],
7049        );
7050
7051        let bid_price = Some(parse_price(&msg.bids[0].price, 4).unwrap());
7052        let bid_size = Some(parse_quantity(&msg.bids[0].size, 0).unwrap());
7053        let ask_price = Some(parse_price(&msg.asks[0].price, 4).unwrap());
7054        let ask_size = Some(parse_quantity(&msg.asks[0].size, 0).unwrap());
7055        let ts_event = parse_millisecond_timestamp(msg.ts);
7056
7057        let quote = cache
7058            .process(
7059                instrument_id,
7060                bid_price,
7061                ask_price,
7062                bid_size,
7063                ask_size,
7064                ts_event,
7065                UnixNanos::default(),
7066            )
7067            .unwrap();
7068
7069        assert_eq!(quote.bid_price, Price::from("0.0035"));
7070        assert_eq!(quote.ask_price, Price::from("0.0040"));
7071        assert_eq!(quote.bid_size, Quantity::from(100));
7072        assert_eq!(quote.ask_size, Quantity::from(200));
7073    }
7074
7075    #[rstest]
7076    fn test_quote_cache_empty_bids_uses_cached_value() {
7077        use nautilus_common::cache::quote::QuoteCache;
7078
7079        let mut cache = QuoteCache::new();
7080        let instrument_id = InstrumentId::from("BTC-USD-260327-80000-C.OKX");
7081
7082        cache
7083            .process(
7084                instrument_id,
7085                Some(Price::from("0.0010")),
7086                Some(Price::from("0.0015")),
7087                Some(Quantity::from(50)),
7088                Some(Quantity::from(75)),
7089                UnixNanos::default(),
7090                UnixNanos::default(),
7091            )
7092            .unwrap();
7093
7094        let msg = stub_book_msg(vec![], vec![stub_book_entry("0.0020", "100")]);
7095        let ask_price = Some(parse_price(&msg.asks[0].price, 4).unwrap());
7096        let ask_size = Some(parse_quantity(&msg.asks[0].size, 0).unwrap());
7097        let ts_event = parse_millisecond_timestamp(msg.ts);
7098
7099        let quote = cache
7100            .process(
7101                instrument_id,
7102                None,
7103                ask_price,
7104                None,
7105                ask_size,
7106                ts_event,
7107                UnixNanos::default(),
7108            )
7109            .unwrap();
7110
7111        assert_eq!(quote.bid_price, Price::from("0.0010"));
7112        assert_eq!(quote.bid_size, Quantity::from(50));
7113        assert_eq!(quote.ask_price, Price::from("0.0020"));
7114        assert_eq!(quote.ask_size, Quantity::from(100));
7115    }
7116
7117    #[rstest]
7118    fn test_quote_cache_empty_asks_uses_cached_value() {
7119        use nautilus_common::cache::quote::QuoteCache;
7120
7121        let mut cache = QuoteCache::new();
7122        let instrument_id = InstrumentId::from("BTC-USD-260327-79000-P.OKX");
7123
7124        cache
7125            .process(
7126                instrument_id,
7127                Some(Price::from("0.0010")),
7128                Some(Price::from("0.0015")),
7129                Some(Quantity::from(50)),
7130                Some(Quantity::from(75)),
7131                UnixNanos::default(),
7132                UnixNanos::default(),
7133            )
7134            .unwrap();
7135
7136        let msg = stub_book_msg(vec![stub_book_entry("0.0012", "60")], vec![]);
7137        let bid_price = Some(parse_price(&msg.bids[0].price, 4).unwrap());
7138        let bid_size = Some(parse_quantity(&msg.bids[0].size, 0).unwrap());
7139        let ts_event = parse_millisecond_timestamp(msg.ts);
7140
7141        let quote = cache
7142            .process(
7143                instrument_id,
7144                bid_price,
7145                None,
7146                bid_size,
7147                None,
7148                ts_event,
7149                UnixNanos::default(),
7150            )
7151            .unwrap();
7152
7153        assert_eq!(quote.bid_price, Price::from("0.0012"));
7154        assert_eq!(quote.bid_size, Quantity::from(60));
7155        assert_eq!(quote.ask_price, Price::from("0.0015"));
7156        assert_eq!(quote.ask_size, Quantity::from(75));
7157    }
7158
7159    #[rstest]
7160    fn test_quote_cache_both_sides_empty_no_cache_returns_error() {
7161        use nautilus_common::cache::quote::QuoteCache;
7162
7163        let mut cache = QuoteCache::new();
7164        let instrument_id = InstrumentId::from("BTC-USD-260327-80000-C.OKX");
7165
7166        let result = cache.process(
7167            instrument_id,
7168            None,
7169            None,
7170            None,
7171            None,
7172            UnixNanos::default(),
7173            UnixNanos::default(),
7174        );
7175
7176        result.unwrap_err();
7177    }
7178
7179    #[rstest]
7180    fn test_quote_cache_both_sides_empty_with_cache_returns_cached() {
7181        use nautilus_common::cache::quote::QuoteCache;
7182
7183        let mut cache = QuoteCache::new();
7184        let instrument_id = InstrumentId::from("BTC-USD-260327-80000-C.OKX");
7185
7186        cache
7187            .process(
7188                instrument_id,
7189                Some(Price::from("0.0010")),
7190                Some(Price::from("0.0015")),
7191                Some(Quantity::from(50)),
7192                Some(Quantity::from(75)),
7193                UnixNanos::default(),
7194                UnixNanos::default(),
7195            )
7196            .unwrap();
7197
7198        let quote = cache
7199            .process(
7200                instrument_id,
7201                None,
7202                None,
7203                None,
7204                None,
7205                UnixNanos::from(1706000000000000000u64),
7206                UnixNanos::from(1706000000000000000u64),
7207            )
7208            .unwrap();
7209
7210        assert_eq!(quote.bid_price, Price::from("0.0010"));
7211        assert_eq!(quote.ask_price, Price::from("0.0015"));
7212        assert_eq!(quote.ts_event, UnixNanos::from(1706000000000000000u64));
7213    }
7214
7215    #[rstest]
7216    fn test_parse_instruments_channel_produces_status() {
7217        use nautilus_model::{enums::MarketStatusAction, identifiers::InstrumentId};
7218
7219        use crate::common::{models::OKXInstrument, parse::parse_instrument_any};
7220
7221        let ts_init = UnixNanos::default();
7222
7223        // Build a cached instrument with fees
7224        let inst_json = serde_json::json!({
7225            "instType": "SPOT",
7226            "instId": "BTC-USD",
7227            "baseCcy": "BTC",
7228            "quoteCcy": "USD",
7229            "settleCcy": "",
7230            "ctVal": "",
7231            "ctMult": "",
7232            "ctValCcy": "",
7233            "optType": "",
7234            "stk": "",
7235            "listTime": "1733454000000",
7236            "expTime": "",
7237            "lever": "",
7238            "tickSz": "0.1",
7239            "lotSz": "0.00000001",
7240            "minSz": "0.00001",
7241            "ctType": "",
7242            "state": "live",
7243            "ruleType": "normal",
7244            "maxLmtSz": "9999999999",
7245            "maxMktSz": "1000000",
7246            "maxLmtAmt": "20000000",
7247            "maxMktAmt": "1000000",
7248            "maxTwapSz": "9999999999",
7249            "maxIcebergSz": "9999999999",
7250            "maxTriggerSz": "9999999999",
7251            "maxStopSz": "1000000",
7252            "uly": "",
7253            "instFamily": ""
7254        });
7255        let initial: OKXInstrument = serde_json::from_value(inst_json).unwrap();
7256        let parsed = parse_instrument_any(&initial, None, None, None, None, ts_init)
7257            .unwrap()
7258            .unwrap();
7259
7260        let mut instruments_cache = AHashMap::new();
7261        instruments_cache.insert(Ustr::from("BTC-USD"), parsed);
7262
7263        let ws_data = serde_json::json!({
7264            "instType": "SPOT",
7265            "instId": "BTC-USD",
7266            "baseCcy": "BTC",
7267            "quoteCcy": "USD",
7268            "settleCcy": "",
7269            "ctVal": "",
7270            "ctMult": "",
7271            "ctValCcy": "",
7272            "optType": "",
7273            "stk": "",
7274            "listTime": "1733454000000",
7275            "expTime": "",
7276            "lever": "",
7277            "tickSz": "0.1",
7278            "lotSz": "0.00000001",
7279            "minSz": "0.00001",
7280            "ctType": "",
7281            "state": "live",
7282            "ruleType": "normal",
7283            "maxLmtSz": "9999999999",
7284            "maxMktSz": "1000000",
7285            "maxLmtAmt": "20000000",
7286            "maxMktAmt": "1000000",
7287            "maxTwapSz": "9999999999",
7288            "maxIcebergSz": "9999999999",
7289            "maxTriggerSz": "9999999999",
7290            "maxStopSz": "1000000",
7291            "uly": "",
7292            "instFamily": ""
7293        });
7294
7295        let instrument_id = InstrumentId::from("BTC-USD.OKX");
7296        let mut funding_cache = AHashMap::new();
7297
7298        let result = parse_ws_message_data(
7299            &OKXWsChannel::Instruments,
7300            ws_data,
7301            &instrument_id,
7302            2,
7303            8,
7304            ts_init,
7305            &mut funding_cache,
7306            &instruments_cache,
7307        )
7308        .expect("Failed to parse instruments channel");
7309
7310        match result {
7311            Some(NautilusWsMessage::Instrument(inst, status)) => {
7312                assert_eq!(inst.id(), InstrumentId::from("BTC-USD.OKX"));
7313                let status = status.expect("Expected InstrumentStatus");
7314                assert_eq!(status.action, MarketStatusAction::Trading);
7315                assert_eq!(status.is_trading, Some(true));
7316            }
7317            other => panic!("Expected Instrument with status, was {other:?}"),
7318        }
7319    }
7320
7321    #[rstest]
7322    fn test_parse_instruments_channel_returns_status_when_definition_invalid() {
7323        use nautilus_model::{enums::MarketStatusAction, identifiers::InstrumentId};
7324
7325        let ts_init = UnixNanos::default();
7326        let ws_data = serde_json::json!({
7327            "instType": "SPOT",
7328            "instId": "USDG-SGD",
7329            "baseCcy": "USDG",
7330            "quoteCcy": "SGD",
7331            "settleCcy": "",
7332            "ctVal": "",
7333            "ctMult": "",
7334            "ctValCcy": "",
7335            "optType": "",
7336            "stk": "",
7337            "listTime": "1733454000000",
7338            "expTime": "",
7339            "lever": "",
7340            "tickSz": "",
7341            "lotSz": "0.00000001",
7342            "minSz": "0.00001",
7343            "ctType": "",
7344            "state": "live",
7345            "ruleType": "normal",
7346            "maxLmtSz": "9999999999",
7347            "maxMktSz": "1000000",
7348            "maxLmtAmt": "20000000",
7349            "maxMktAmt": "1000000",
7350            "maxTwapSz": "9999999999",
7351            "maxIcebergSz": "9999999999",
7352            "maxTriggerSz": "9999999999",
7353            "maxStopSz": "1000000",
7354            "uly": "",
7355            "instFamily": ""
7356        });
7357
7358        let mut funding_cache = AHashMap::new();
7359        let instruments_cache = AHashMap::new();
7360        let result = parse_ws_message_data(
7361            &OKXWsChannel::Instruments,
7362            ws_data,
7363            &InstrumentId::from("BTC-USD.OKX"),
7364            2,
7365            8,
7366            ts_init,
7367            &mut funding_cache,
7368            &instruments_cache,
7369        )
7370        .expect("Failed to parse instruments channel");
7371
7372        match result {
7373            Some(NautilusWsMessage::InstrumentStatus(status)) => {
7374                assert_eq!(status.instrument_id, InstrumentId::from("USDG-SGD.OKX"));
7375                assert_eq!(status.action, MarketStatusAction::Trading);
7376                assert_eq!(status.is_trading, Some(true));
7377            }
7378            other => panic!("Expected InstrumentStatus, was {other:?}"),
7379        }
7380    }
7381
7382    #[rstest]
7383    fn test_parse_instruments_channel_suspend_status() {
7384        use nautilus_model::{enums::MarketStatusAction, identifiers::InstrumentId};
7385
7386        use crate::common::{models::OKXInstrument, parse::parse_instrument_any};
7387
7388        let ts_init = UnixNanos::default();
7389
7390        let inst_json = serde_json::json!({
7391            "instType": "SPOT",
7392            "instId": "BTC-USD",
7393            "baseCcy": "BTC",
7394            "quoteCcy": "USD",
7395            "settleCcy": "",
7396            "ctVal": "",
7397            "ctMult": "",
7398            "ctValCcy": "",
7399            "optType": "",
7400            "stk": "",
7401            "listTime": "1733454000000",
7402            "expTime": "",
7403            "lever": "",
7404            "tickSz": "0.1",
7405            "lotSz": "0.00000001",
7406            "minSz": "0.00001",
7407            "ctType": "",
7408            "state": "live",
7409            "ruleType": "normal",
7410            "maxLmtSz": "9999999999",
7411            "maxMktSz": "1000000",
7412            "maxLmtAmt": "20000000",
7413            "maxMktAmt": "1000000",
7414            "maxTwapSz": "9999999999",
7415            "maxIcebergSz": "9999999999",
7416            "maxTriggerSz": "9999999999",
7417            "maxStopSz": "1000000",
7418            "uly": "",
7419            "instFamily": ""
7420        });
7421        let initial: OKXInstrument = serde_json::from_value(inst_json).unwrap();
7422        let parsed = parse_instrument_any(&initial, None, None, None, None, ts_init)
7423            .unwrap()
7424            .unwrap();
7425
7426        let mut instruments_cache = AHashMap::new();
7427        instruments_cache.insert(Ustr::from("BTC-USD"), parsed);
7428
7429        // WS update with suspend state
7430        let ws_data = serde_json::json!({
7431            "instType": "SPOT",
7432            "instId": "BTC-USD",
7433            "baseCcy": "BTC",
7434            "quoteCcy": "USD",
7435            "settleCcy": "",
7436            "ctVal": "",
7437            "ctMult": "",
7438            "ctValCcy": "",
7439            "optType": "",
7440            "stk": "",
7441            "listTime": "1733454000000",
7442            "expTime": "",
7443            "lever": "",
7444            "tickSz": "0.1",
7445            "lotSz": "0.00000001",
7446            "minSz": "0.00001",
7447            "ctType": "",
7448            "state": "suspend",
7449            "ruleType": "normal",
7450            "maxLmtSz": "9999999999",
7451            "maxMktSz": "1000000",
7452            "maxLmtAmt": "20000000",
7453            "maxMktAmt": "1000000",
7454            "maxTwapSz": "9999999999",
7455            "maxIcebergSz": "9999999999",
7456            "maxTriggerSz": "9999999999",
7457            "maxStopSz": "1000000",
7458            "uly": "",
7459            "instFamily": ""
7460        });
7461
7462        let instrument_id = InstrumentId::from("BTC-USD.OKX");
7463        let mut funding_cache = AHashMap::new();
7464
7465        let result = parse_ws_message_data(
7466            &OKXWsChannel::Instruments,
7467            ws_data,
7468            &instrument_id,
7469            2,
7470            8,
7471            ts_init,
7472            &mut funding_cache,
7473            &instruments_cache,
7474        )
7475        .expect("Failed to parse instruments channel");
7476
7477        match result {
7478            Some(NautilusWsMessage::Instrument(_, status)) => {
7479                let status = status.expect("Expected InstrumentStatus");
7480                assert_eq!(status.action, MarketStatusAction::Suspend);
7481                assert_eq!(status.is_trading, Some(false));
7482            }
7483            other => panic!("Expected Instrument with status, was {other:?}"),
7484        }
7485    }
7486
7487    #[rstest]
7488    fn test_parse_option_summary_greeks() {
7489        let json_str = load_test_json("ws_opt_summary.json");
7490        let msgs: Vec<OKXOptionSummaryMsg> =
7491            serde_json::from_str(&json_str).expect("Failed to deserialize opt-summary fixture");
7492        assert_eq!(msgs.len(), 2);
7493
7494        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
7495        let ts_init = UnixNanos::from(1_711_612_900_000_000_000u64);
7496        let greeks =
7497            parse_option_summary_greeks(&msgs[0], &instrument_id, OKXGreeksType::Bs, ts_init)
7498                .expect("parse failed");
7499
7500        assert_eq!(greeks.instrument_id, instrument_id);
7501        assert!((greeks.greeks.delta - 0.5312).abs() < 1e-10);
7502        assert!((greeks.greeks.gamma - 0.0000134).abs() < 1e-15);
7503        assert!((greeks.greeks.vega - 0.0038).abs() < 1e-10);
7504        assert!((greeks.greeks.theta - (-0.0015)).abs() < 1e-10);
7505        assert!((greeks.greeks.rho - 0.0).abs() < 1e-10);
7506        assert!((greeks.mark_iv.unwrap() - 0.53).abs() < 1e-10);
7507        assert!((greeks.bid_iv.unwrap() - 0.52).abs() < 1e-10);
7508        assert!((greeks.ask_iv.unwrap() - 0.55).abs() < 1e-10);
7509        assert!((greeks.underlying_price.unwrap() - 92150.50).abs() < 1e-10);
7510        assert!(greeks.open_interest.is_none());
7511        assert_eq!(greeks.convention, GreeksConvention::BlackScholes);
7512        assert_eq!(
7513            greeks.ts_event,
7514            UnixNanos::from(1_711_612_800_000_000_000u64)
7515        );
7516        assert_eq!(greeks.ts_init, ts_init);
7517    }
7518
7519    #[rstest]
7520    fn test_option_summary_msg_deserializes_with_uppercase_bs_alias() {
7521        let json = r#"{
7522            "instId": "BTC-USD-250328-92000-C",
7523            "uly": "BTC-USD",
7524            "delta": "0.52",
7525            "gamma": "0.00001",
7526            "theta": "-0.001",
7527            "vega": "0.003",
7528            "deltaBS": "0.53",
7529            "gammaBS": "0.00002",
7530            "thetaBS": "-0.002",
7531            "vegaBS": "0.004",
7532            "realVol": "0.45",
7533            "bidVol": "0.50",
7534            "askVol": "0.55",
7535            "markVol": "0.52",
7536            "lever": "10.0",
7537            "ts": "1711612800000"
7538        }"#;
7539        let msg: OKXOptionSummaryMsg =
7540            serde_json::from_str(json).expect("deltaBS alias failed to deserialize");
7541        assert_eq!(msg.delta_bs, "0.53");
7542        assert_eq!(msg.gamma_bs, "0.00002");
7543        assert_eq!(msg.theta_bs, "-0.002");
7544        assert_eq!(msg.vega_bs, "0.004");
7545    }
7546
7547    #[rstest]
7548    fn test_parse_option_summary_greeks_put() {
7549        let json_str = load_test_json("ws_opt_summary.json");
7550        let msgs: Vec<OKXOptionSummaryMsg> =
7551            serde_json::from_str(&json_str).expect("Failed to deserialize opt-summary fixture");
7552
7553        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-P.OKX");
7554        let ts_init = UnixNanos::from(1_711_612_900_000_000_000u64);
7555        let greeks =
7556            parse_option_summary_greeks(&msgs[1], &instrument_id, OKXGreeksType::Bs, ts_init)
7557                .expect("parse failed");
7558
7559        assert!((greeks.greeks.delta - (-0.4688)).abs() < 1e-10);
7560    }
7561
7562    #[rstest]
7563    fn test_parse_option_summary_greeks_pa() {
7564        let json_str = load_test_json("ws_opt_summary.json");
7565        let msgs: Vec<OKXOptionSummaryMsg> =
7566            serde_json::from_str(&json_str).expect("Failed to deserialize opt-summary fixture");
7567        assert_eq!(msgs.len(), 2);
7568
7569        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
7570        let ts_init = UnixNanos::from(1_711_612_900_000_000_000u64);
7571        let greeks =
7572            parse_option_summary_greeks(&msgs[0], &instrument_id, OKXGreeksType::Pa, ts_init)
7573                .expect("parse failed");
7574
7575        assert_eq!(greeks.instrument_id, instrument_id);
7576        assert!((greeks.greeks.delta - 0.5234).abs() < 1e-10);
7577        assert!((greeks.greeks.gamma - 0.0000123).abs() < 1e-15);
7578        assert!((greeks.greeks.vega - 0.0034).abs() < 1e-10);
7579        assert!((greeks.greeks.theta - (-0.0012)).abs() < 1e-10);
7580        assert!((greeks.greeks.rho - 0.0).abs() < 1e-10);
7581        assert!((greeks.mark_iv.unwrap() - 0.53).abs() < 1e-10);
7582        assert!((greeks.bid_iv.unwrap() - 0.52).abs() < 1e-10);
7583        assert!((greeks.ask_iv.unwrap() - 0.55).abs() < 1e-10);
7584        assert!((greeks.underlying_price.unwrap() - 92150.50).abs() < 1e-10);
7585        assert_eq!(greeks.convention, GreeksConvention::PriceAdjusted);
7586    }
7587
7588    #[rstest]
7589    fn test_parse_option_summary_greeks_pa_put() {
7590        let json_str = load_test_json("ws_opt_summary.json");
7591        let msgs: Vec<OKXOptionSummaryMsg> =
7592            serde_json::from_str(&json_str).expect("Failed to deserialize opt-summary fixture");
7593
7594        let instrument_id = InstrumentId::from("BTC-USD-250328-92000-P.OKX");
7595        let ts_init = UnixNanos::from(1_711_612_900_000_000_000u64);
7596        let greeks =
7597            parse_option_summary_greeks(&msgs[1], &instrument_id, OKXGreeksType::Pa, ts_init)
7598                .expect("parse failed");
7599
7600        assert!((greeks.greeks.delta - (-0.4766)).abs() < 1e-10);
7601    }
7602
7603    #[rstest]
7604    fn test_option_greeks_filtering_only_subscribed_instruments() {
7605        use ahash::AHashSet;
7606
7607        let json_str = load_test_json("ws_opt_summary.json");
7608        let msgs: Vec<OKXOptionSummaryMsg> =
7609            serde_json::from_str(&json_str).expect("Failed to deserialize");
7610
7611        let call_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
7612        let put_id = InstrumentId::from("BTC-USD-250328-92000-P.OKX");
7613        let ts_init = UnixNanos::from(1_711_612_900_000_000_000u64);
7614
7615        // Subscribe to CALL only
7616        let mut subs = AHashSet::new();
7617        subs.insert(call_id);
7618
7619        let mut results = Vec::new();
7620
7621        for msg in &msgs {
7622            let inst_id_str = format!("{}.OKX", msg.inst_id);
7623            let instrument_id = InstrumentId::from(inst_id_str.as_str());
7624            if !subs.contains(&instrument_id) {
7625                continue;
7626            }
7627
7628            if let Ok(greeks) =
7629                parse_option_summary_greeks(msg, &instrument_id, OKXGreeksType::Bs, ts_init)
7630            {
7631                results.push(greeks);
7632            }
7633        }
7634
7635        assert_eq!(results.len(), 1);
7636        assert_eq!(results[0].instrument_id, call_id);
7637        assert!((results[0].greeks.delta - 0.5312).abs() < 1e-10);
7638
7639        // Now subscribe to both
7640        subs.insert(put_id);
7641
7642        let mut results = Vec::new();
7643
7644        for msg in &msgs {
7645            let inst_id_str = format!("{}.OKX", msg.inst_id);
7646            let instrument_id = InstrumentId::from(inst_id_str.as_str());
7647            if !subs.contains(&instrument_id) {
7648                continue;
7649            }
7650
7651            if let Ok(greeks) =
7652                parse_option_summary_greeks(msg, &instrument_id, OKXGreeksType::Bs, ts_init)
7653            {
7654                results.push(greeks);
7655            }
7656        }
7657
7658        assert_eq!(results.len(), 2);
7659    }
7660
7661    #[rstest]
7662    fn test_option_greeks_unsubscribed_instrument_filtered_out() {
7663        use ahash::AHashSet;
7664
7665        let json_str = load_test_json("ws_opt_summary.json");
7666        let msgs: Vec<OKXOptionSummaryMsg> =
7667            serde_json::from_str(&json_str).expect("Failed to deserialize");
7668
7669        let ts_init = UnixNanos::default();
7670
7671        // Empty subscription set
7672        let subs: AHashSet<InstrumentId> = AHashSet::new();
7673
7674        let mut results = Vec::new();
7675
7676        for msg in &msgs {
7677            let inst_id_str = format!("{}.OKX", msg.inst_id);
7678            let instrument_id = InstrumentId::from(inst_id_str.as_str());
7679            if !subs.contains(&instrument_id) {
7680                continue;
7681            }
7682
7683            if let Ok(greeks) =
7684                parse_option_summary_greeks(msg, &instrument_id, OKXGreeksType::Bs, ts_init)
7685            {
7686                results.push(greeks);
7687            }
7688        }
7689
7690        assert!(results.is_empty());
7691    }
7692
7693    #[rstest]
7694    fn test_option_greeks_family_dedup_subscribe_count() {
7695        use crate::common::parse::extract_inst_family;
7696
7697        let mut family_subs: AHashMap<Ustr, usize> = AHashMap::new();
7698
7699        let call_id = InstrumentId::from("BTC-USD-250328-92000-C.OKX");
7700        let put_id = InstrumentId::from("BTC-USD-250328-92000-P.OKX");
7701        let other_id = InstrumentId::from("BTC-USD-250328-80000-C.OKX");
7702
7703        // Subscribe first instrument: count goes to 1 (triggers WS subscribe)
7704        let family = extract_inst_family(call_id.symbol.inner().as_str()).unwrap();
7705        let count = family_subs.entry(family).or_default();
7706        *count += 1;
7707        assert_eq!(*count, 1);
7708        let should_subscribe_ws = *count == 1;
7709        assert!(should_subscribe_ws);
7710
7711        // Subscribe second instrument in same family: count goes to 2 (no WS subscribe)
7712        let family = extract_inst_family(put_id.symbol.inner().as_str()).unwrap();
7713        let count = family_subs.entry(family).or_default();
7714        *count += 1;
7715        assert_eq!(*count, 2);
7716        let should_subscribe_ws = *count == 1;
7717        assert!(!should_subscribe_ws);
7718
7719        // Subscribe third instrument in same family: count goes to 3
7720        let family = extract_inst_family(other_id.symbol.inner().as_str()).unwrap();
7721        let count = family_subs.entry(family).or_default();
7722        *count += 1;
7723        assert_eq!(*count, 3);
7724
7725        // Unsubscribe one: count goes to 2 (no WS unsubscribe)
7726        let family = extract_inst_family(call_id.symbol.inner().as_str()).unwrap();
7727        if let Some(count) = family_subs.get_mut(&family) {
7728            *count = count.saturating_sub(1);
7729            assert_eq!(*count, 2);
7730            let should_unsubscribe_ws = *count == 0;
7731            assert!(!should_unsubscribe_ws);
7732        }
7733
7734        // Unsubscribe second: count goes to 1
7735        let family = extract_inst_family(put_id.symbol.inner().as_str()).unwrap();
7736        if let Some(count) = family_subs.get_mut(&family) {
7737            *count = count.saturating_sub(1);
7738            assert_eq!(*count, 1);
7739        }
7740
7741        // Unsubscribe last: count goes to 0 (triggers WS unsubscribe)
7742        let family = extract_inst_family(other_id.symbol.inner().as_str()).unwrap();
7743        if let Some(count) = family_subs.get_mut(&family) {
7744            *count = count.saturating_sub(1);
7745            assert_eq!(*count, 0);
7746            let should_unsubscribe_ws = *count == 0;
7747            assert!(should_unsubscribe_ws);
7748        }
7749    }
7750
7751    #[rstest]
7752    fn test_parse_event_contract_markets_returns_raw_message() {
7753        let data = serde_json::json!([
7754            {
7755                "seriesId": "BTC-ABOVE-DAILY",
7756                "eventId": "BTC-ABOVE-DAILY-260224-1600",
7757                "instId": "BTC-ABOVE-DAILY-260224-1600-65000",
7758                "listTime": "1769697132335",
7759                "fixTime": "",
7760                "expTime": "1769697132335",
7761                "state": "live",
7762                "outcome": "0",
7763                "floorStrike": "120000",
7764                "capStrike": "",
7765                "settleValue": "",
7766                "disputed": false,
7767                "hitDir": ""
7768            },
7769            {
7770                "seriesId": "BTC-HIT-MONTHLY",
7771                "eventId": "BTC-HIT-MONTHLY-260831-1600",
7772                "instId": "BTC-HIT-MONTHLY-260831-1600-37500",
7773                "listTime": "1785513600000",
7774                "fixTime": "",
7775                "expTime": "1788192000000",
7776                "state": "live",
7777                "outcome": "0",
7778                "floorStrike": "37500",
7779                "capStrike": "",
7780                "settleValue": "",
7781                "disputed": false,
7782                "hitDir": "dn"
7783            }
7784        ]);
7785        let instrument_id = InstrumentId::from("BTC-ABOVE-DAILY-260224-1600-65000.OKX");
7786        let mut funding_cache = AHashMap::new();
7787        let instruments_cache = AHashMap::new();
7788
7789        let result = parse_ws_message_data(
7790            &OKXWsChannel::EventContractMarkets,
7791            data.clone(),
7792            &instrument_id,
7793            2,
7794            2,
7795            UnixNanos::default(),
7796            &mut funding_cache,
7797            &instruments_cache,
7798        )
7799        .unwrap();
7800
7801        match result {
7802            Some(NautilusWsMessage::Raw(raw)) => assert_eq!(raw, data),
7803            _ => panic!("Expected raw event contract market payload"),
7804        }
7805    }
7806
7807    #[rstest]
7808    fn test_parse_ws_message_data_spread_public_trades() {
7809        // sprd-public-trades keys the spread as `sprdId` and omits `count`; the
7810        // instrument is resolved from the channel arg, so the trade still parses.
7811        let data = serde_json::json!([{
7812            "sprdId": "ETH-USD-260925_ETH-USD-261225",
7813            "tradeId": "3392538740127301632",
7814            "px": "16.9",
7815            "sz": "100",
7816            "side": "sell",
7817            "ts": "1780047866507"
7818        }]);
7819        let instrument_id = InstrumentId::from("ETH-USD-260925_ETH-USD-261225.OKX");
7820        let mut funding_cache = AHashMap::new();
7821        let instruments_cache = AHashMap::new();
7822
7823        let result = parse_ws_message_data(
7824            &OKXWsChannel::SprdPublicTrades,
7825            data,
7826            &instrument_id,
7827            1,
7828            0,
7829            UnixNanos::default(),
7830            &mut funding_cache,
7831            &instruments_cache,
7832        )
7833        .unwrap();
7834
7835        let Some(NautilusWsMessage::Data(data_vec)) = result else {
7836            panic!("expected Data variant, was {result:?}");
7837        };
7838        assert_eq!(data_vec.len(), 1);
7839        let Data::Trade(trade) = &data_vec[0] else {
7840            panic!("expected Data::Trade, was {:?}", data_vec[0]);
7841        };
7842        assert_eq!(trade.instrument_id, instrument_id);
7843        assert_eq!(trade.price.as_decimal(), dec!(16.9));
7844        assert_eq!(trade.size.as_decimal(), dec!(100));
7845        assert_eq!(trade.aggressor_side, AggressorSide::Sell);
7846    }
7847
7848    #[rstest]
7849    fn test_parse_spread_books5_snapshot_with_three_element_levels() {
7850        // sprd-books5 pushes a full snapshot with 3-element `[price, size, count]`
7851        // levels; it is parsed as a snapshot (F_SNAPSHOT), not an incremental update.
7852        let msg: OKXBookMsg = serde_json::from_value(serde_json::json!({
7853            "asks": [["16.7", "100", "1"]],
7854            "bids": [["16.65", "100", "1"]],
7855            "ts": "1780044924909",
7856            "seqId": 1779935772619784_u64,
7857        }))
7858        .unwrap();
7859        let instrument_id = InstrumentId::from("ETH-USD-260925_ETH-USD-261225.OKX");
7860
7861        let deltas = parse_book_msg(
7862            &msg,
7863            instrument_id,
7864            2,
7865            0,
7866            &OKXBookAction::Snapshot,
7867            UnixNanos::default(),
7868        )
7869        .unwrap();
7870
7871        assert_eq!(deltas.instrument_id, instrument_id);
7872        assert_eq!(deltas.flags, RecordFlag::F_SNAPSHOT as u8);
7873        let bid = deltas
7874            .deltas
7875            .iter()
7876            .find(|d| d.order.side == OrderSide::Buy.into())
7877            .expect("should have a bid delta");
7878        let ask = deltas
7879            .deltas
7880            .iter()
7881            .find(|d| d.order.side == OrderSide::Sell.into())
7882            .expect("should have an ask delta");
7883        assert_eq!(bid.order.price.as_decimal(), dec!(16.65));
7884        assert_eq!(ask.order.price.as_decimal(), dec!(16.7));
7885    }
7886}