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