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