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