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