nautilus_okx/websocket/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2025 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Functions translating raw OKX WebSocket frames into Nautilus data types.
17
18use std::str::FromStr;
19
20use ahash::AHashMap;
21use nautilus_core::{UUID4, nanos::UnixNanos};
22use nautilus_model::{
23    data::{
24        Bar, BarSpecification, BarType, BookOrder, Data, FundingRateUpdate, IndexPriceUpdate,
25        MarkPriceUpdate, OrderBookDelta, OrderBookDeltas, OrderBookDeltas_API, OrderBookDepth10,
26        QuoteTick, TradeTick, depth::DEPTH10_LEN,
27    },
28    enums::{
29        AggregationSource, AggressorSide, BookAction, LiquiditySide, OrderSide, OrderStatus,
30        OrderType, RecordFlag, TimeInForce, TriggerType,
31    },
32    events::{OrderAccepted, OrderCanceled, OrderExpired, OrderTriggered, OrderUpdated},
33    identifiers::{
34        AccountId, ClientOrderId, InstrumentId, StrategyId, TradeId, TraderId, VenueOrderId,
35    },
36    instruments::{Instrument, InstrumentAny},
37    reports::{FillReport, OrderStatusReport},
38    types::{Money, Price, Quantity},
39};
40use rust_decimal::Decimal;
41use ustr::Ustr;
42
43use super::{
44    enums::OKXWsChannel,
45    messages::{
46        OKXAlgoOrderMsg, OKXBookMsg, OKXCandleMsg, OKXIndexPriceMsg, OKXMarkPriceMsg, OKXOrderMsg,
47        OKXTickerMsg, OKXTradeMsg, OrderBookEntry,
48    },
49};
50use crate::{
51    common::{
52        consts::{OKX_POST_ONLY_CANCEL_REASON, OKX_POST_ONLY_CANCEL_SOURCE},
53        enums::{
54            OKXBookAction, OKXCandleConfirm, OKXInstrumentType, OKXOrderCategory, OKXOrderStatus,
55            OKXOrderType, OKXSide, OKXTargetCurrency, OKXTriggerType,
56        },
57        models::OKXInstrument,
58        parse::{
59            determine_order_type, is_market_price, okx_channel_to_bar_spec, parse_client_order_id,
60            parse_fee, parse_fee_currency, parse_funding_rate_msg, parse_instrument_any,
61            parse_message_vec, parse_millisecond_timestamp, parse_price, parse_quantity,
62        },
63    },
64    websocket::messages::{ExecutionReport, NautilusWsMessage, OKXFundingRateMsg},
65};
66
67/// Extracts fee rates from a cached instrument.
68///
69/// Returns a tuple of (margin_init, margin_maint, maker_fee, taker_fee).
70/// All values are None if the instrument type doesn't support fees.
71fn extract_fees_from_cached_instrument(
72    instrument: &InstrumentAny,
73) -> (
74    Option<Decimal>,
75    Option<Decimal>,
76    Option<Decimal>,
77    Option<Decimal>,
78) {
79    match instrument {
80        InstrumentAny::CurrencyPair(pair) => (
81            Some(pair.margin_init),
82            Some(pair.margin_maint),
83            Some(pair.maker_fee),
84            Some(pair.taker_fee),
85        ),
86        InstrumentAny::CryptoPerpetual(perp) => (
87            Some(perp.margin_init),
88            Some(perp.margin_maint),
89            Some(perp.maker_fee),
90            Some(perp.taker_fee),
91        ),
92        InstrumentAny::CryptoFuture(future) => (
93            Some(future.margin_init),
94            Some(future.margin_maint),
95            Some(future.maker_fee),
96            Some(future.taker_fee),
97        ),
98        InstrumentAny::CryptoOption(option) => (
99            Some(option.margin_init),
100            Some(option.margin_maint),
101            Some(option.maker_fee),
102            Some(option.taker_fee),
103        ),
104        _ => (None, None, None, None),
105    }
106}
107
108/// Represents the result of parsing an OKX order message into a specific event.
109#[derive(Debug, Clone)]
110pub enum ParsedOrderEvent {
111    /// Order has been accepted by the venue.
112    Accepted(OrderAccepted),
113    /// Order has been canceled.
114    Canceled(OrderCanceled),
115    /// Order has expired (e.g., GTD order reached expiration time).
116    Expired(OrderExpired),
117    /// Stop/algo order has been triggered.
118    Triggered(OrderTriggered),
119    /// Order has been modified (price, quantity, or venue order ID changed).
120    Updated(OrderUpdated),
121    /// Order fill event.
122    Fill(FillReport),
123    /// Status update that doesn't map to a specific event (for reconciliation/external orders).
124    StatusOnly(Box<OrderStatusReport>),
125}
126
127/// Snapshot of order state for detecting updates.
128#[derive(Debug, Clone)]
129pub struct OrderStateSnapshot {
130    pub venue_order_id: VenueOrderId,
131    pub quantity: Quantity,
132    pub price: Option<Price>,
133}
134
135/// Parses an OKX order message into a specific order event.
136///
137/// This function determines the appropriate event type based on:
138/// - Current order status from OKX
139/// - Whether there's a new fill
140/// - Whether the order was updated (price/quantity change)
141/// - Whether it's an algo order that triggered
142///
143/// # Errors
144///
145/// Returns an error if parsing order identifiers or numeric fields fails.
146#[allow(clippy::too_many_arguments)]
147pub fn parse_order_event(
148    msg: &OKXOrderMsg,
149    client_order_id: ClientOrderId,
150    account_id: AccountId,
151    trader_id: TraderId,
152    strategy_id: StrategyId,
153    instrument: &InstrumentAny,
154    previous_fee: Option<Money>,
155    previous_filled_qty: Option<Quantity>,
156    previous_state: Option<&OrderStateSnapshot>,
157    ts_init: UnixNanos,
158) -> anyhow::Result<ParsedOrderEvent> {
159    let venue_order_id = VenueOrderId::new(msg.ord_id);
160    let instrument_id = instrument.id();
161
162    let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0")
163        || !msg.trade_id.is_empty()
164        || has_acc_fill_sz_increased(
165            &msg.acc_fill_sz,
166            previous_filled_qty,
167            instrument.size_precision(),
168        );
169
170    // Check for order updates, but skip when other events take precedence:
171    // - Fill events: fill data must be processed, update detection secondary
172    // - Terminal states: handled by specific branches below
173    let skip_update_check = has_new_fill
174        || matches!(
175            msg.state,
176            OKXOrderStatus::Filled | OKXOrderStatus::Canceled | OKXOrderStatus::MmpCanceled
177        );
178
179    if !skip_update_check
180        && let Some(prev) = previous_state
181        && is_order_updated_excluding_venue_id_for_live(msg, prev, instrument)?
182    {
183        let ts_event = parse_millisecond_timestamp(msg.u_time);
184        let quantity = parse_quantity(&msg.sz, instrument.size_precision())?;
185        let price = if !is_market_price(&msg.px) {
186            Some(parse_price(&msg.px, instrument.price_precision())?)
187        } else {
188            None
189        };
190
191        return Ok(ParsedOrderEvent::Updated(OrderUpdated::new(
192            trader_id,
193            strategy_id,
194            instrument_id,
195            client_order_id,
196            quantity,
197            UUID4::new(),
198            ts_event,
199            ts_init,
200            false, // reconciliation
201            Some(venue_order_id),
202            Some(account_id),
203            price,
204            None, // trigger_price
205            None, // protection_price
206        )));
207    }
208
209    match msg.state {
210        OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled if has_new_fill => {
211            parse_fill_report(
212                msg,
213                instrument,
214                account_id,
215                previous_fee,
216                previous_filled_qty,
217                ts_init,
218            )
219            .map(ParsedOrderEvent::Fill)
220        }
221        OKXOrderStatus::Live => {
222            let ts_event = parse_millisecond_timestamp(msg.c_time);
223            Ok(ParsedOrderEvent::Accepted(OrderAccepted::new(
224                trader_id,
225                strategy_id,
226                instrument_id,
227                client_order_id,
228                venue_order_id,
229                account_id,
230                UUID4::new(),
231                ts_event,
232                ts_init,
233                false, // reconciliation
234            )))
235        }
236        OKXOrderStatus::Canceled | OKXOrderStatus::MmpCanceled => {
237            let ts_event = parse_millisecond_timestamp(msg.u_time);
238
239            if is_order_expired_by_reason(msg) {
240                Ok(ParsedOrderEvent::Expired(OrderExpired::new(
241                    trader_id,
242                    strategy_id,
243                    instrument_id,
244                    client_order_id,
245                    UUID4::new(),
246                    ts_event,
247                    ts_init,
248                    false,
249                    Some(venue_order_id),
250                    Some(account_id),
251                )))
252            } else {
253                Ok(ParsedOrderEvent::Canceled(OrderCanceled::new(
254                    trader_id,
255                    strategy_id,
256                    instrument_id,
257                    client_order_id,
258                    UUID4::new(),
259                    ts_event,
260                    ts_init,
261                    false,
262                    Some(venue_order_id),
263                    Some(account_id),
264                )))
265            }
266        }
267        OKXOrderStatus::Effective | OKXOrderStatus::OrderPlaced => {
268            let ts_event = parse_millisecond_timestamp(msg.u_time);
269            Ok(ParsedOrderEvent::Triggered(OrderTriggered::new(
270                trader_id,
271                strategy_id,
272                instrument_id,
273                client_order_id,
274                UUID4::new(),
275                ts_event,
276                ts_init,
277                false,
278                Some(venue_order_id),
279                Some(account_id),
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/// Case-insensitive substring check.
291#[inline]
292fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
293    haystack
294        .as_bytes()
295        .windows(needle.len())
296        .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
297}
298
299/// Determines if a Canceled order is actually an Expired order based on cancel reason.
300fn is_order_expired_by_reason(msg: &OKXOrderMsg) -> bool {
301    if let Some(ref reason) = msg.cancel_source_reason
302        && (contains_ignore_ascii_case(reason, "expir")
303            || contains_ignore_ascii_case(reason, "gtd")
304            || contains_ignore_ascii_case(reason, "timeout")
305            || contains_ignore_ascii_case(reason, "time_expired"))
306    {
307        return true;
308    }
309
310    // OKX cancel source codes that indicate expiration
311    if let Some(ref source) = msg.cancel_source
312        && (source == "5" || source == "time_expired" || source == "gtd_expired")
313    {
314        return true;
315    }
316
317    false
318}
319
320/// Checks if order parameters have been updated compared to previous state.
321///
322/// For Live status, venue_id changes are ignored because algo triggers create new IDs
323/// but should emit OrderAccepted, not OrderUpdated. Price/quantity changes still count.
324fn is_order_updated_excluding_venue_id_for_live(
325    msg: &OKXOrderMsg,
326    previous: &OrderStateSnapshot,
327    instrument: &InstrumentAny,
328) -> anyhow::Result<bool> {
329    // For non-Live statuses, venue_id change indicates amendment
330    if msg.state != OKXOrderStatus::Live {
331        let current_venue_id = VenueOrderId::new(msg.ord_id);
332        if previous.venue_order_id != current_venue_id {
333            return Ok(true);
334        }
335    }
336
337    let current_qty = parse_quantity(&msg.sz, instrument.size_precision())?;
338    if previous.quantity != current_qty {
339        return Ok(true);
340    }
341
342    // Price change only applies to limit orders
343    if !is_market_price(&msg.px) {
344        let current_price = parse_price(&msg.px, instrument.price_precision())?;
345        if let Some(prev_price) = previous.price
346            && prev_price != current_price
347        {
348            return Ok(true);
349        }
350    }
351
352    Ok(false)
353}
354
355/// Checks if order parameters have been updated (used by tests).
356#[cfg(test)]
357fn is_order_updated(
358    msg: &OKXOrderMsg,
359    previous: &OrderStateSnapshot,
360    instrument: &InstrumentAny,
361) -> anyhow::Result<bool> {
362    let current_venue_id = VenueOrderId::new(msg.ord_id);
363
364    // Venue order ID change indicates amendment
365    if previous.venue_order_id != current_venue_id {
366        return Ok(true);
367    }
368
369    let current_qty = parse_quantity(&msg.sz, instrument.size_precision())?;
370    if previous.quantity != current_qty {
371        return Ok(true);
372    }
373
374    // Price change only applies to limit orders
375    if !is_market_price(&msg.px) {
376        let current_price = parse_price(&msg.px, instrument.price_precision())?;
377        if let Some(prev_price) = previous.price
378            && prev_price != current_price
379        {
380            return Ok(true);
381        }
382    }
383
384    Ok(false)
385}
386
387/// Parses vector of OKX book messages into Nautilus order book deltas.
388///
389/// # Errors
390///
391/// Returns an error if any underlying book message cannot be parsed.
392pub fn parse_book_msg_vec(
393    data: Vec<OKXBookMsg>,
394    instrument_id: &InstrumentId,
395    price_precision: u8,
396    size_precision: u8,
397    action: OKXBookAction,
398    ts_init: UnixNanos,
399) -> anyhow::Result<Vec<Data>> {
400    let mut deltas = Vec::with_capacity(data.len());
401
402    for msg in data {
403        let deltas_api = OrderBookDeltas_API::new(parse_book_msg(
404            &msg,
405            *instrument_id,
406            price_precision,
407            size_precision,
408            &action,
409            ts_init,
410        )?);
411        deltas.push(Data::Deltas(deltas_api));
412    }
413
414    Ok(deltas)
415}
416
417/// Parses vector of OKX ticker messages into Nautilus quote ticks.
418///
419/// # Errors
420///
421/// Returns an error if any ticker message fails to parse.
422pub fn parse_ticker_msg_vec(
423    data: serde_json::Value,
424    instrument_id: &InstrumentId,
425    price_precision: u8,
426    size_precision: u8,
427    ts_init: UnixNanos,
428) -> anyhow::Result<Vec<Data>> {
429    parse_message_vec(
430        data,
431        |msg| {
432            parse_ticker_msg(
433                msg,
434                *instrument_id,
435                price_precision,
436                size_precision,
437                ts_init,
438            )
439        },
440        Data::Quote,
441    )
442}
443
444/// Parses vector of OKX book messages into Nautilus quote ticks.
445///
446/// # Errors
447///
448/// Returns an error if any quote message fails to parse.
449pub fn parse_quote_msg_vec(
450    data: serde_json::Value,
451    instrument_id: &InstrumentId,
452    price_precision: u8,
453    size_precision: u8,
454    ts_init: UnixNanos,
455) -> anyhow::Result<Vec<Data>> {
456    parse_message_vec(
457        data,
458        |msg| {
459            parse_quote_msg(
460                msg,
461                *instrument_id,
462                price_precision,
463                size_precision,
464                ts_init,
465            )
466        },
467        Data::Quote,
468    )
469}
470
471/// Parses vector of OKX trade messages into Nautilus trade ticks.
472///
473/// # Errors
474///
475/// Returns an error if any trade message fails to parse.
476pub fn parse_trade_msg_vec(
477    data: serde_json::Value,
478    instrument_id: &InstrumentId,
479    price_precision: u8,
480    size_precision: u8,
481    ts_init: UnixNanos,
482) -> anyhow::Result<Vec<Data>> {
483    parse_message_vec(
484        data,
485        |msg| {
486            parse_trade_msg(
487                msg,
488                *instrument_id,
489                price_precision,
490                size_precision,
491                ts_init,
492            )
493        },
494        Data::Trade,
495    )
496}
497
498/// Parses vector of OKX mark price messages into Nautilus mark price updates.
499///
500/// # Errors
501///
502/// Returns an error if any mark price message fails to parse.
503pub fn parse_mark_price_msg_vec(
504    data: serde_json::Value,
505    instrument_id: &InstrumentId,
506    price_precision: u8,
507    ts_init: UnixNanos,
508) -> anyhow::Result<Vec<Data>> {
509    parse_message_vec(
510        data,
511        |msg| parse_mark_price_msg(msg, *instrument_id, price_precision, ts_init),
512        Data::MarkPriceUpdate,
513    )
514}
515
516/// Parses vector of OKX index price messages into Nautilus index price updates.
517///
518/// # Errors
519///
520/// Returns an error if any index price message fails to parse.
521pub fn parse_index_price_msg_vec(
522    data: serde_json::Value,
523    instrument_id: &InstrumentId,
524    price_precision: u8,
525    ts_init: UnixNanos,
526) -> anyhow::Result<Vec<Data>> {
527    parse_message_vec(
528        data,
529        |msg| parse_index_price_msg(msg, *instrument_id, price_precision, ts_init),
530        Data::IndexPriceUpdate,
531    )
532}
533
534/// Parses vector of OKX funding rate messages into Nautilus funding rate updates.
535/// Includes caching to filter out duplicate funding rates.
536///
537/// # Errors
538///
539/// Returns an error if any funding rate message fails to parse.
540pub fn parse_funding_rate_msg_vec(
541    data: serde_json::Value,
542    instrument_id: &InstrumentId,
543    ts_init: UnixNanos,
544    funding_cache: &mut AHashMap<Ustr, (Ustr, u64)>,
545) -> anyhow::Result<Vec<FundingRateUpdate>> {
546    let msgs: Vec<OKXFundingRateMsg> = serde_json::from_value(data)?;
547
548    let mut result = Vec::with_capacity(msgs.len());
549    for msg in &msgs {
550        let cache_key = (msg.funding_rate, msg.funding_time);
551
552        if let Some(cached) = funding_cache.get(&msg.inst_id)
553            && *cached == cache_key
554        {
555            continue; // Skip duplicate
556        }
557
558        // New or changed funding rate, update cache and parse
559        funding_cache.insert(msg.inst_id, cache_key);
560        let funding_rate = parse_funding_rate_msg(msg, *instrument_id, ts_init)?;
561        result.push(funding_rate);
562    }
563
564    Ok(result)
565}
566
567/// Parses vector of OKX candle messages into Nautilus bars.
568///
569/// # Errors
570///
571/// Returns an error if candle messages cannot be deserialized or parsed.
572pub fn parse_candle_msg_vec(
573    data: serde_json::Value,
574    instrument_id: &InstrumentId,
575    price_precision: u8,
576    size_precision: u8,
577    spec: BarSpecification,
578    ts_init: UnixNanos,
579) -> anyhow::Result<Vec<Data>> {
580    let msgs: Vec<OKXCandleMsg> = serde_json::from_value(data)?;
581    let bar_type = BarType::new(*instrument_id, spec, AggregationSource::External);
582    let mut bars = Vec::with_capacity(msgs.len());
583
584    for msg in msgs {
585        // Only process completed candles to avoid duplicate/partial bars
586        if msg.confirm == OKXCandleConfirm::Closed {
587            let bar = parse_candle_msg(&msg, bar_type, price_precision, size_precision, ts_init)?;
588            bars.push(Data::Bar(bar));
589        }
590    }
591
592    Ok(bars)
593}
594
595/// Parses vector of OKX book messages into Nautilus depth10 updates.
596///
597/// # Errors
598///
599/// Returns an error if any book10 message fails to parse.
600pub fn parse_book10_msg_vec(
601    data: Vec<OKXBookMsg>,
602    instrument_id: &InstrumentId,
603    price_precision: u8,
604    size_precision: u8,
605    ts_init: UnixNanos,
606) -> anyhow::Result<Vec<Data>> {
607    let mut depth10_updates = Vec::with_capacity(data.len());
608
609    for msg in data {
610        let depth10 = parse_book10_msg(
611            &msg,
612            *instrument_id,
613            price_precision,
614            size_precision,
615            ts_init,
616        )?;
617        depth10_updates.push(Data::Depth10(Box::new(depth10)));
618    }
619
620    Ok(depth10_updates)
621}
622
623/// Parses an OKX book message into Nautilus order book deltas.
624///
625/// # Errors
626///
627/// Returns an error if bid or ask levels contain values that cannot be parsed.
628pub fn parse_book_msg(
629    msg: &OKXBookMsg,
630    instrument_id: InstrumentId,
631    price_precision: u8,
632    size_precision: u8,
633    action: &OKXBookAction,
634    ts_init: UnixNanos,
635) -> anyhow::Result<OrderBookDeltas> {
636    let flags = if action == &OKXBookAction::Snapshot {
637        RecordFlag::F_SNAPSHOT as u8
638    } else {
639        0
640    };
641    let ts_event = parse_millisecond_timestamp(msg.ts);
642
643    let mut deltas = Vec::with_capacity(msg.asks.len() + msg.bids.len());
644
645    for bid in &msg.bids {
646        let book_action = match action {
647            OKXBookAction::Snapshot => BookAction::Add,
648            _ => match bid.size.as_str() {
649                "0" => BookAction::Delete,
650                _ => BookAction::Update,
651            },
652        };
653        let price = parse_price(&bid.price, price_precision)?;
654        let size = parse_quantity(&bid.size, size_precision)?;
655        let order_id = 0; // TBD
656        let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
657        let delta = OrderBookDelta::new(
658            instrument_id,
659            book_action,
660            order,
661            flags,
662            msg.seq_id,
663            ts_event,
664            ts_init,
665        );
666        deltas.push(delta);
667    }
668
669    for ask in &msg.asks {
670        let book_action = match action {
671            OKXBookAction::Snapshot => BookAction::Add,
672            _ => match ask.size.as_str() {
673                "0" => BookAction::Delete,
674                _ => BookAction::Update,
675            },
676        };
677        let price = parse_price(&ask.price, price_precision)?;
678        let size = parse_quantity(&ask.size, size_precision)?;
679        let order_id = 0; // TBD
680        let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
681        let delta = OrderBookDelta::new(
682            instrument_id,
683            book_action,
684            order,
685            flags,
686            msg.seq_id,
687            ts_event,
688            ts_init,
689        );
690        deltas.push(delta);
691    }
692
693    OrderBookDeltas::new_checked(instrument_id, deltas)
694}
695
696/// Parses an OKX book message into a Nautilus quote tick.
697///
698/// # Errors
699///
700/// Returns an error if any quote levels contain values that cannot be parsed.
701pub fn parse_quote_msg(
702    msg: &OKXBookMsg,
703    instrument_id: InstrumentId,
704    price_precision: u8,
705    size_precision: u8,
706    ts_init: UnixNanos,
707) -> anyhow::Result<QuoteTick> {
708    let best_bid: &OrderBookEntry = &msg.bids[0];
709    let best_ask: &OrderBookEntry = &msg.asks[0];
710
711    let bid_price = parse_price(&best_bid.price, price_precision)?;
712    let ask_price = parse_price(&best_ask.price, price_precision)?;
713    let bid_size = parse_quantity(&best_bid.size, size_precision)?;
714    let ask_size = parse_quantity(&best_ask.size, size_precision)?;
715    let ts_event = parse_millisecond_timestamp(msg.ts);
716
717    QuoteTick::new_checked(
718        instrument_id,
719        bid_price,
720        ask_price,
721        bid_size,
722        ask_size,
723        ts_event,
724        ts_init,
725    )
726}
727
728/// Parses an OKX book message into a Nautilus [`OrderBookDepth10`].
729///
730/// Converts order book data into a fixed-depth snapshot with top 10 levels for both sides.
731///
732/// # Errors
733///
734/// Returns an error if price or size fields cannot be parsed for any level.
735pub fn parse_book10_msg(
736    msg: &OKXBookMsg,
737    instrument_id: InstrumentId,
738    price_precision: u8,
739    size_precision: u8,
740    ts_init: UnixNanos,
741) -> anyhow::Result<OrderBookDepth10> {
742    // Initialize arrays - need to fill all 10 levels even if we have fewer
743    let mut bids: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
744    let mut asks: [BookOrder; DEPTH10_LEN] = [BookOrder::default(); DEPTH10_LEN];
745    let mut bid_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
746    let mut ask_counts: [u32; DEPTH10_LEN] = [0; DEPTH10_LEN];
747
748    // Parse available bid levels (up to 10)
749    let bid_len = msg.bids.len().min(DEPTH10_LEN);
750    for (i, level) in msg.bids.iter().take(DEPTH10_LEN).enumerate() {
751        let price = parse_price(&level.price, price_precision)?;
752        let size = parse_quantity(&level.size, size_precision)?;
753        let orders_count = level.orders_count.parse::<u32>().unwrap_or(1);
754
755        let bid_order = BookOrder::new(OrderSide::Buy, price, size, 0);
756        bids[i] = bid_order;
757        bid_counts[i] = orders_count;
758    }
759
760    // Fill remaining bid slots with empty Buy orders (not NULL orders)
761    for i in bid_len..DEPTH10_LEN {
762        bids[i] = BookOrder::new(
763            OrderSide::Buy,
764            Price::zero(price_precision),
765            Quantity::zero(size_precision),
766            0,
767        );
768        bid_counts[i] = 0;
769    }
770
771    // Parse available ask levels (up to 10)
772    let ask_len = msg.asks.len().min(DEPTH10_LEN);
773    for (i, level) in msg.asks.iter().take(DEPTH10_LEN).enumerate() {
774        let price = parse_price(&level.price, price_precision)?;
775        let size = parse_quantity(&level.size, size_precision)?;
776        let orders_count = level.orders_count.parse::<u32>().unwrap_or(1);
777
778        let ask_order = BookOrder::new(OrderSide::Sell, price, size, 0);
779        asks[i] = ask_order;
780        ask_counts[i] = orders_count;
781    }
782
783    // Fill remaining ask slots with empty Sell orders (not NULL orders)
784    for i in ask_len..DEPTH10_LEN {
785        asks[i] = BookOrder::new(
786            OrderSide::Sell,
787            Price::zero(price_precision),
788            Quantity::zero(size_precision),
789            0,
790        );
791        ask_counts[i] = 0;
792    }
793
794    let ts_event = parse_millisecond_timestamp(msg.ts);
795
796    Ok(OrderBookDepth10::new(
797        instrument_id,
798        bids,
799        asks,
800        bid_counts,
801        ask_counts,
802        RecordFlag::F_SNAPSHOT as u8,
803        msg.seq_id, // Use sequence ID for OKX L2 books
804        ts_event,
805        ts_init,
806    ))
807}
808
809/// Parses an OKX ticker message into a Nautilus quote tick.
810///
811/// # Errors
812///
813/// Returns an error if bid or ask values cannot be parsed from the message.
814pub fn parse_ticker_msg(
815    msg: &OKXTickerMsg,
816    instrument_id: InstrumentId,
817    price_precision: u8,
818    size_precision: u8,
819    ts_init: UnixNanos,
820) -> anyhow::Result<QuoteTick> {
821    let bid_price = parse_price(&msg.bid_px, price_precision)?;
822    let ask_price = parse_price(&msg.ask_px, price_precision)?;
823    let bid_size = parse_quantity(&msg.bid_sz, size_precision)?;
824    let ask_size = parse_quantity(&msg.ask_sz, size_precision)?;
825    let ts_event = parse_millisecond_timestamp(msg.ts);
826
827    QuoteTick::new_checked(
828        instrument_id,
829        bid_price,
830        ask_price,
831        bid_size,
832        ask_size,
833        ts_event,
834        ts_init,
835    )
836}
837
838/// Parses an OKX trade message into a Nautilus trade tick.
839///
840/// # Errors
841///
842/// Returns an error if trade prices or sizes cannot be parsed.
843pub fn parse_trade_msg(
844    msg: &OKXTradeMsg,
845    instrument_id: InstrumentId,
846    price_precision: u8,
847    size_precision: u8,
848    ts_init: UnixNanos,
849) -> anyhow::Result<TradeTick> {
850    let price = parse_price(&msg.px, price_precision)?;
851    let size = parse_quantity(&msg.sz, size_precision)?;
852    let aggressor_side: AggressorSide = msg.side.into();
853    let trade_id = TradeId::new(&msg.trade_id);
854    let ts_event = parse_millisecond_timestamp(msg.ts);
855
856    TradeTick::new_checked(
857        instrument_id,
858        price,
859        size,
860        aggressor_side,
861        trade_id,
862        ts_event,
863        ts_init,
864    )
865}
866
867/// Parses an OKX mark price message into a Nautilus mark price update.
868///
869/// # Errors
870///
871/// Returns an error if the mark price fails to parse.
872pub fn parse_mark_price_msg(
873    msg: &OKXMarkPriceMsg,
874    instrument_id: InstrumentId,
875    price_precision: u8,
876    ts_init: UnixNanos,
877) -> anyhow::Result<MarkPriceUpdate> {
878    let price = parse_price(&msg.mark_px, price_precision)?;
879    let ts_event = parse_millisecond_timestamp(msg.ts);
880
881    Ok(MarkPriceUpdate::new(
882        instrument_id,
883        price,
884        ts_event,
885        ts_init,
886    ))
887}
888
889/// Parses an OKX index price message into a Nautilus index price update.
890///
891/// # Errors
892///
893/// Returns an error if the index price fails to parse.
894pub fn parse_index_price_msg(
895    msg: &OKXIndexPriceMsg,
896    instrument_id: InstrumentId,
897    price_precision: u8,
898    ts_init: UnixNanos,
899) -> anyhow::Result<IndexPriceUpdate> {
900    let price = parse_price(&msg.idx_px, price_precision)?;
901    let ts_event = parse_millisecond_timestamp(msg.ts);
902
903    Ok(IndexPriceUpdate::new(
904        instrument_id,
905        price,
906        ts_event,
907        ts_init,
908    ))
909}
910
911/// Parses an OKX candle message into a Nautilus bar.
912///
913/// # Errors
914///
915/// Returns an error if candle price or volume fields cannot be parsed.
916pub fn parse_candle_msg(
917    msg: &OKXCandleMsg,
918    bar_type: BarType,
919    price_precision: u8,
920    size_precision: u8,
921    ts_init: UnixNanos,
922) -> anyhow::Result<Bar> {
923    let open = parse_price(&msg.o, price_precision)?;
924    let high = parse_price(&msg.h, price_precision)?;
925    let low = parse_price(&msg.l, price_precision)?;
926    let close = parse_price(&msg.c, price_precision)?;
927    let volume = parse_quantity(&msg.vol, size_precision)?;
928    let ts_event = parse_millisecond_timestamp(msg.ts);
929
930    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
931}
932
933/// Parses vector of OKX order messages into Nautilus execution reports.
934///
935/// # Errors
936///
937/// Returns an error if any contained order messages cannot be parsed.
938pub fn parse_order_msg_vec(
939    data: Vec<OKXOrderMsg>,
940    account_id: AccountId,
941    instruments: &AHashMap<Ustr, InstrumentAny>,
942    fee_cache: &AHashMap<Ustr, Money>,
943    filled_qty_cache: &AHashMap<Ustr, Quantity>,
944    ts_init: UnixNanos,
945) -> anyhow::Result<Vec<ExecutionReport>> {
946    let mut order_reports = Vec::with_capacity(data.len());
947
948    for msg in data {
949        match parse_order_msg(
950            &msg,
951            account_id,
952            instruments,
953            fee_cache,
954            filled_qty_cache,
955            ts_init,
956        ) {
957            Ok(report) => order_reports.push(report),
958            Err(e) => tracing::error!("Failed to parse execution report from message: {e}"),
959        }
960    }
961
962    Ok(order_reports)
963}
964
965/// Checks if acc_fill_sz has increased compared to the previous filled quantity.
966fn has_acc_fill_sz_increased(
967    acc_fill_sz: &Option<String>,
968    previous_filled_qty: Option<Quantity>,
969    size_precision: u8,
970) -> bool {
971    if let Some(acc_str) = acc_fill_sz {
972        if acc_str.is_empty() || acc_str == "0" {
973            return false;
974        }
975        if let Ok(current_filled) = parse_quantity(acc_str, size_precision) {
976            if let Some(prev_qty) = previous_filled_qty {
977                return current_filled > prev_qty;
978            }
979            return !current_filled.is_zero();
980        }
981    }
982    false
983}
984
985/// Parses a single OKX order message into an [`ExecutionReport`].
986///
987/// # Errors
988///
989/// Returns an error if the instrument cannot be found or if parsing the
990/// underlying order payload fails.
991pub fn parse_order_msg(
992    msg: &OKXOrderMsg,
993    account_id: AccountId,
994    instruments: &AHashMap<Ustr, InstrumentAny>,
995    fee_cache: &AHashMap<Ustr, Money>,
996    filled_qty_cache: &AHashMap<Ustr, Quantity>,
997    ts_init: UnixNanos,
998) -> anyhow::Result<ExecutionReport> {
999    let instrument = instruments
1000        .get(&msg.inst_id)
1001        .ok_or_else(|| anyhow::anyhow!("No instrument found for inst_id: {}", msg.inst_id))?;
1002
1003    let previous_fee = fee_cache.get(&msg.ord_id).copied();
1004    let previous_filled_qty = filled_qty_cache.get(&msg.ord_id).copied();
1005
1006    let has_new_fill = (!msg.fill_sz.is_empty() && msg.fill_sz != "0")
1007        || !msg.trade_id.is_empty()
1008        || has_acc_fill_sz_increased(
1009            &msg.acc_fill_sz,
1010            previous_filled_qty,
1011            instrument.size_precision(),
1012        );
1013
1014    match msg.state {
1015        OKXOrderStatus::Filled | OKXOrderStatus::PartiallyFilled if has_new_fill => {
1016            parse_fill_report(
1017                msg,
1018                instrument,
1019                account_id,
1020                previous_fee,
1021                previous_filled_qty,
1022                ts_init,
1023            )
1024            .map(ExecutionReport::Fill)
1025        }
1026        _ => parse_order_status_report(msg, instrument, account_id, ts_init)
1027            .map(ExecutionReport::Order),
1028    }
1029}
1030
1031/// Parses an OKX algo order message into a Nautilus execution report.
1032///
1033/// # Errors
1034///
1035/// Returns an error if the instrument cannot be found or if message fields
1036/// fail to parse.
1037pub fn parse_algo_order_msg(
1038    msg: OKXAlgoOrderMsg,
1039    account_id: AccountId,
1040    instruments: &AHashMap<Ustr, InstrumentAny>,
1041    ts_init: UnixNanos,
1042) -> anyhow::Result<ExecutionReport> {
1043    let inst = instruments
1044        .get(&msg.inst_id)
1045        .ok_or_else(|| anyhow::anyhow!("No instrument found for inst_id: {}", msg.inst_id))?;
1046
1047    // Algo orders primarily return status reports (not fills since they haven't been triggered yet)
1048    parse_algo_order_status_report(&msg, inst, account_id, ts_init).map(ExecutionReport::Order)
1049}
1050
1051/// Parses an OKX algo order message into a Nautilus order status report.
1052///
1053/// # Errors
1054///
1055/// Returns an error if any order identifiers or numeric fields cannot be
1056/// parsed.
1057pub fn parse_algo_order_status_report(
1058    msg: &OKXAlgoOrderMsg,
1059    instrument: &InstrumentAny,
1060    account_id: AccountId,
1061    ts_init: UnixNanos,
1062) -> anyhow::Result<OrderStatusReport> {
1063    // For algo orders, use algo_cl_ord_id if cl_ord_id is empty
1064    let client_order_id = if msg.cl_ord_id.is_empty() {
1065        parse_client_order_id(&msg.algo_cl_ord_id)
1066    } else {
1067        parse_client_order_id(&msg.cl_ord_id)
1068    };
1069
1070    // For algo orders that haven't triggered, ord_id will be empty, use algo_id instead
1071    let venue_order_id = if msg.ord_id.is_empty() {
1072        VenueOrderId::new(msg.algo_id.as_str())
1073    } else {
1074        VenueOrderId::new(msg.ord_id.as_str())
1075    };
1076
1077    let order_side: OrderSide = msg.side.into();
1078
1079    // Determine order type based on ord_px for conditional/stop orders
1080    let order_type = if is_market_price(&msg.ord_px) {
1081        OrderType::StopMarket
1082    } else {
1083        OrderType::StopLimit
1084    };
1085
1086    let status: OrderStatus = msg.state.into();
1087
1088    let quantity = parse_quantity(msg.sz.as_str(), instrument.size_precision())?;
1089
1090    // For algo orders, actual_sz represents filled quantity (if any)
1091    let filled_qty = if msg.actual_sz.is_empty() || msg.actual_sz == "0" {
1092        Quantity::zero(instrument.size_precision())
1093    } else {
1094        parse_quantity(msg.actual_sz.as_str(), instrument.size_precision())?
1095    };
1096
1097    let trigger_px = parse_price(msg.trigger_px.as_str(), instrument.price_precision())?;
1098
1099    // Parse limit price if it exists (not -1)
1100    let price = if msg.ord_px != "-1" {
1101        Some(parse_price(
1102            msg.ord_px.as_str(),
1103            instrument.price_precision(),
1104        )?)
1105    } else {
1106        None
1107    };
1108
1109    let trigger_type = match msg.trigger_px_type {
1110        OKXTriggerType::Last => TriggerType::LastPrice,
1111        OKXTriggerType::Mark => TriggerType::MarkPrice,
1112        OKXTriggerType::Index => TriggerType::IndexPrice,
1113        OKXTriggerType::None => TriggerType::Default,
1114    };
1115
1116    let mut report = OrderStatusReport::new(
1117        account_id,
1118        instrument.id(),
1119        client_order_id,
1120        venue_order_id,
1121        order_side,
1122        order_type,
1123        TimeInForce::Gtc, // Algo orders are typically GTC
1124        status,
1125        quantity,
1126        filled_qty,
1127        msg.c_time.into(), // ts_accepted
1128        msg.u_time.into(), // ts_last
1129        ts_init,
1130        None, // report_id - auto-generated
1131    );
1132
1133    report.trigger_price = Some(trigger_px);
1134    report.trigger_type = Some(trigger_type);
1135
1136    if let Some(limit_price) = price {
1137        report.price = Some(limit_price);
1138    }
1139
1140    Ok(report)
1141}
1142
1143/// Parses an OKX order message into a Nautilus order status report.
1144///
1145/// # Errors
1146///
1147/// Returns an error if order metadata or numeric values cannot be parsed.
1148pub fn parse_order_status_report(
1149    msg: &OKXOrderMsg,
1150    instrument: &InstrumentAny,
1151    account_id: AccountId,
1152    ts_init: UnixNanos,
1153) -> anyhow::Result<OrderStatusReport> {
1154    let client_order_id = parse_client_order_id(&msg.cl_ord_id);
1155    let venue_order_id = VenueOrderId::new(msg.ord_id);
1156    let order_side: OrderSide = msg.side.into();
1157
1158    let okx_order_type = msg.ord_type;
1159
1160    // Determine order type based on presence of limit price for certain OKX order types
1161    let order_type = match okx_order_type {
1162        OKXOrderType::Trigger => {
1163            if is_market_price(&msg.px) {
1164                OrderType::StopMarket
1165            } else {
1166                OrderType::StopLimit
1167            }
1168        }
1169        OKXOrderType::Fok | OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => {
1170            determine_order_type(okx_order_type, &msg.px)
1171        }
1172        _ => msg.ord_type.into(),
1173    };
1174    let order_status: OrderStatus = msg.state.into();
1175
1176    let time_in_force = match okx_order_type {
1177        OKXOrderType::Fok => TimeInForce::Fok,
1178        OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
1179        _ => TimeInForce::Gtc,
1180    };
1181
1182    let size_precision = instrument.size_precision();
1183
1184    // Parse quantities based on target currency
1185    // OKX always returns acc_fill_sz in base currency, but sz depends on tgt_ccy
1186
1187    // Determine if this is a quote-quantity order
1188    // Method 1: Explicit tgt_ccy field set to QuoteCcy
1189    let is_quote_qty_explicit = msg.tgt_ccy == Some(OKXTargetCurrency::QuoteCcy);
1190
1191    // Method 2: Use OKX defaults when tgt_ccy is None (old orders or missing field)
1192    // OKX API defaults for SPOT market orders: BUY orders use quote_ccy, SELL orders use base_ccy
1193    // Note: tgtCcy only applies to SPOT market orders (not limit orders)
1194    // For limit orders, sz is always in base currency regardless of side
1195    let is_quote_qty_heuristic = msg.tgt_ccy.is_none()
1196        && (msg.inst_type == OKXInstrumentType::Spot || msg.inst_type == OKXInstrumentType::Margin)
1197        && msg.side == OKXSide::Buy
1198        && order_type == OrderType::Market;
1199
1200    let (quantity, filled_qty) = if is_quote_qty_explicit || is_quote_qty_heuristic {
1201        // Quote-quantity order: sz is in quote currency, need to convert to base
1202        let sz_quote_dec = Decimal::from_str(&msg.sz).map_err(|e| {
1203            anyhow::anyhow!("Failed to parse sz='{}' as quote quantity: {}", msg.sz, e)
1204        })?;
1205
1206        // Determine the price to use for conversion
1207        // Priority: 1) limit price (px) for limit orders, 2) avg_px for market orders
1208        let conversion_price_dec =
1209            if !is_market_price(&msg.px) {
1210                // Limit order: use the limit price (msg.px)
1211                Some(
1212                    Decimal::from_str(&msg.px)
1213                        .map_err(|e| anyhow::anyhow!("Failed to parse px='{}': {}", msg.px, e))?,
1214                )
1215            } else if !msg.avg_px.is_empty() && msg.avg_px != "0" {
1216                // Market order with fills: use average fill price
1217                Some(Decimal::from_str(&msg.avg_px).map_err(|e| {
1218                    anyhow::anyhow!("Failed to parse avg_px='{}': {}", msg.avg_px, e)
1219                })?)
1220            } else {
1221                None
1222            };
1223
1224        // Convert quote quantity to base: quantity_base = sz_quote / price
1225        let quantity_base = if let Some(price) = conversion_price_dec {
1226            if !price.is_zero() {
1227                Quantity::from_decimal_dp(sz_quote_dec / price, size_precision)?
1228            } else {
1229                parse_quantity(&msg.sz, size_precision)?
1230            }
1231        } else {
1232            // No price available, can't convert - use sz as-is temporarily
1233            // This will be corrected once the order gets filled and price is available
1234            parse_quantity(&msg.sz, size_precision)?
1235        };
1236
1237        let filled_qty =
1238            parse_quantity(&msg.acc_fill_sz.clone().unwrap_or_default(), size_precision)?;
1239
1240        (quantity_base, filled_qty)
1241    } else {
1242        // Base-quantity order: both sz and acc_fill_sz are in base currency
1243        let quantity = parse_quantity(&msg.sz, size_precision)?;
1244        let filled_qty =
1245            parse_quantity(&msg.acc_fill_sz.clone().unwrap_or_default(), size_precision)?;
1246
1247        (quantity, filled_qty)
1248    };
1249
1250    // For quote-quantity orders marked as FILLED, adjust quantity to match filled_qty
1251    // to avoid precision mismatches from quote-to-base conversion
1252    let (quantity, filled_qty) = if (is_quote_qty_explicit || is_quote_qty_heuristic)
1253        && msg.state == OKXOrderStatus::Filled
1254        && filled_qty.is_positive()
1255    {
1256        (filled_qty, filled_qty)
1257    } else {
1258        (quantity, filled_qty)
1259    };
1260
1261    let ts_accepted = parse_millisecond_timestamp(msg.c_time);
1262    let ts_last = parse_millisecond_timestamp(msg.u_time);
1263
1264    let is_liquidation = matches!(
1265        msg.category,
1266        OKXOrderCategory::FullLiquidation | OKXOrderCategory::PartialLiquidation
1267    );
1268
1269    let is_adl = msg.category == OKXOrderCategory::Adl;
1270
1271    if is_liquidation {
1272        tracing::warn!(
1273            order_id = msg.ord_id.as_str(),
1274            category = ?msg.category,
1275            inst_id = msg.inst_id.as_str(),
1276            state = ?msg.state,
1277            "Liquidation order status update"
1278        );
1279    }
1280
1281    if is_adl {
1282        tracing::warn!(
1283            order_id = msg.ord_id.as_str(),
1284            inst_id = msg.inst_id.as_str(),
1285            state = ?msg.state,
1286            "ADL (Auto-Deleveraging) order status update"
1287        );
1288    }
1289
1290    let mut report = OrderStatusReport::new(
1291        account_id,
1292        instrument.id(),
1293        client_order_id,
1294        venue_order_id,
1295        order_side,
1296        order_type,
1297        time_in_force,
1298        order_status,
1299        quantity,
1300        filled_qty,
1301        ts_accepted,
1302        ts_init,
1303        ts_last,
1304        None, // Generate UUID4 automatically
1305    );
1306
1307    let price_precision = instrument.price_precision();
1308
1309    if okx_order_type == OKXOrderType::Trigger {
1310        // For triggered orders coming through regular orders channel,
1311        // set the price if it's a stop-limit order
1312        if !is_market_price(&msg.px)
1313            && let Ok(price) = parse_price(&msg.px, price_precision)
1314        {
1315            report = report.with_price(price);
1316        }
1317    } else {
1318        // For regular orders, use px field
1319        if !is_market_price(&msg.px)
1320            && let Ok(price) = parse_price(&msg.px, price_precision)
1321        {
1322            report = report.with_price(price);
1323        }
1324    }
1325
1326    if !msg.avg_px.is_empty()
1327        && let Ok(avg_px) = msg.avg_px.parse::<f64>()
1328    {
1329        report = report.with_avg_px(avg_px)?;
1330    }
1331
1332    if matches!(
1333        msg.ord_type,
1334        OKXOrderType::PostOnly | OKXOrderType::MmpAndPostOnly
1335    ) || matches!(
1336        msg.cancel_source.as_deref(),
1337        Some(source) if source == OKX_POST_ONLY_CANCEL_SOURCE
1338    ) || matches!(
1339        msg.cancel_source_reason.as_deref(),
1340        Some(reason) if reason.contains("POST_ONLY")
1341    ) {
1342        report = report.with_post_only(true);
1343    }
1344
1345    if msg.reduce_only == "true" {
1346        report = report.with_reduce_only(true);
1347    }
1348
1349    if let Some(reason) = msg
1350        .cancel_source_reason
1351        .as_ref()
1352        .filter(|reason| !reason.is_empty())
1353    {
1354        report = report.with_cancel_reason(reason.clone());
1355    } else if let Some(source) = msg
1356        .cancel_source
1357        .as_ref()
1358        .filter(|source| !source.is_empty())
1359    {
1360        let reason = if source == OKX_POST_ONLY_CANCEL_SOURCE {
1361            OKX_POST_ONLY_CANCEL_REASON.to_string()
1362        } else {
1363            format!("cancel_source={source}")
1364        };
1365        report = report.with_cancel_reason(reason);
1366    }
1367
1368    Ok(report)
1369}
1370
1371/// Parses an OKX order message into a Nautilus fill report.
1372///
1373/// # Errors
1374///
1375/// Returns an error if order quantities, prices, or fees cannot be parsed.
1376pub fn parse_fill_report(
1377    msg: &OKXOrderMsg,
1378    instrument: &InstrumentAny,
1379    account_id: AccountId,
1380    previous_fee: Option<Money>,
1381    previous_filled_qty: Option<Quantity>,
1382    ts_init: UnixNanos,
1383) -> anyhow::Result<FillReport> {
1384    let client_order_id = parse_client_order_id(&msg.cl_ord_id);
1385    let venue_order_id = VenueOrderId::new(msg.ord_id);
1386
1387    // TODO: Extract to dedicated function:
1388    // OKX may not provide a trade_id, so generate a UUID4 as fallback
1389    let trade_id = if msg.trade_id.is_empty() {
1390        TradeId::new(UUID4::new().as_str())
1391    } else {
1392        TradeId::new(&msg.trade_id)
1393    };
1394
1395    let order_side: OrderSide = msg.side.into();
1396
1397    let price_precision = instrument.price_precision();
1398    let size_precision = instrument.size_precision();
1399
1400    let price_str = if !msg.fill_px.is_empty() {
1401        &msg.fill_px
1402    } else if !msg.avg_px.is_empty() {
1403        &msg.avg_px
1404    } else {
1405        &msg.px
1406    };
1407    let last_px = parse_price(price_str, price_precision).map_err(|e| {
1408        anyhow::anyhow!(
1409            "Failed to parse price (fill_px='{}', avg_px='{}', px='{}'): {}",
1410            msg.fill_px,
1411            msg.avg_px,
1412            msg.px,
1413            e
1414        )
1415    })?;
1416
1417    // OKX provides fillSz (incremental fill) or accFillSz (cumulative total)
1418    // If fillSz is provided, use it directly as the incremental fill quantity
1419    let last_qty = if !msg.fill_sz.is_empty() && msg.fill_sz != "0" {
1420        parse_quantity(&msg.fill_sz, size_precision)
1421            .map_err(|e| anyhow::anyhow!("Failed to parse fill_sz='{}': {e}", msg.fill_sz,))?
1422    } else if let Some(ref acc_fill_sz) = msg.acc_fill_sz {
1423        // If fillSz is missing but accFillSz is available, calculate incremental fill
1424        if !acc_fill_sz.is_empty() && acc_fill_sz != "0" {
1425            let current_filled = parse_quantity(acc_fill_sz, size_precision).map_err(|e| {
1426                anyhow::anyhow!("Failed to parse acc_fill_sz='{acc_fill_sz}': {e}",)
1427            })?;
1428
1429            // Calculate incremental fill as: current_total - previous_total
1430            if let Some(prev_qty) = previous_filled_qty {
1431                let incremental = current_filled - prev_qty;
1432                if incremental.is_zero() {
1433                    anyhow::bail!(
1434                        "Incremental fill quantity is zero (acc_fill_sz='{acc_fill_sz}', previous_filled_qty={prev_qty})"
1435                    );
1436                }
1437                incremental
1438            } else {
1439                // First fill, use accumulated as incremental
1440                current_filled
1441            }
1442        } else {
1443            anyhow::bail!(
1444                "Cannot determine fill quantity: fill_sz is empty/zero and acc_fill_sz is empty/zero"
1445            );
1446        }
1447    } else {
1448        anyhow::bail!(
1449            "Cannot determine fill quantity: fill_sz='{}' and acc_fill_sz is None",
1450            msg.fill_sz
1451        );
1452    };
1453
1454    let fee_str = msg.fee.as_deref().unwrap_or("0");
1455    let fee_dec = Decimal::from_str(fee_str)
1456        .map_err(|e| anyhow::anyhow!("Failed to parse fee '{fee_str}': {e}"))?;
1457
1458    let fee_currency = parse_fee_currency(msg.fee_ccy.as_str(), fee_dec, || {
1459        format!("fill report for inst_id={}", msg.inst_id)
1460    });
1461
1462    // OKX sends fees as negative numbers (e.g., "-2.5" for a $2.5 charge), parse_fee negates to positive
1463    let total_fee = parse_fee(msg.fee.as_deref(), fee_currency)
1464        .map_err(|e| anyhow::anyhow!("Failed to parse fee={:?}: {}", msg.fee, e))?;
1465
1466    // OKX sends cumulative fees, so we subtract the previous total to get this fill's fee
1467    let commission = if let Some(previous_fee) = previous_fee {
1468        let incremental = total_fee - previous_fee;
1469
1470        if incremental < Money::zero(fee_currency) {
1471            tracing::debug!(
1472                order_id = msg.ord_id.as_str(),
1473                total_fee = %total_fee,
1474                previous_fee = %previous_fee,
1475                incremental = %incremental,
1476                "Negative incremental fee detected - likely a maker rebate or fee refund"
1477            );
1478        }
1479
1480        // Skip corruption check when previous is negative (rebate), as transitions from
1481        // rebate to charge legitimately have incremental > total (e.g., -1 → +2 gives +3)
1482        if previous_fee >= Money::zero(fee_currency)
1483            && total_fee > Money::zero(fee_currency)
1484            && incremental > total_fee
1485        {
1486            tracing::error!(
1487                order_id = msg.ord_id.as_str(),
1488                total_fee = %total_fee,
1489                previous_fee = %previous_fee,
1490                incremental = %incremental,
1491                "Incremental fee exceeds total fee - likely fee cache corruption, using total fee as fallback"
1492            );
1493            total_fee
1494        } else {
1495            incremental
1496        }
1497    } else {
1498        total_fee
1499    };
1500
1501    let liquidity_side: LiquiditySide = msg.exec_type.into();
1502    let ts_event = parse_millisecond_timestamp(msg.fill_time);
1503
1504    let is_liquidation = matches!(
1505        msg.category,
1506        OKXOrderCategory::FullLiquidation | OKXOrderCategory::PartialLiquidation
1507    );
1508
1509    let is_adl = msg.category == OKXOrderCategory::Adl;
1510
1511    if is_liquidation {
1512        tracing::warn!(
1513            order_id = msg.ord_id.as_str(),
1514            category = ?msg.category,
1515            inst_id = msg.inst_id.as_str(),
1516            side = ?msg.side,
1517            fill_sz = %msg.fill_sz,
1518            fill_px = %msg.fill_px,
1519            "Liquidation order detected"
1520        );
1521    }
1522
1523    if is_adl {
1524        tracing::warn!(
1525            order_id = msg.ord_id.as_str(),
1526            inst_id = msg.inst_id.as_str(),
1527            side = ?msg.side,
1528            fill_sz = %msg.fill_sz,
1529            fill_px = %msg.fill_px,
1530            "ADL (Auto-Deleveraging) order detected"
1531        );
1532    }
1533
1534    let report = FillReport::new(
1535        account_id,
1536        instrument.id(),
1537        venue_order_id,
1538        trade_id,
1539        order_side,
1540        last_qty,
1541        last_px,
1542        commission,
1543        liquidity_side,
1544        client_order_id,
1545        None,
1546        ts_event,
1547        ts_init,
1548        None, // Generate UUID4 automatically
1549    );
1550
1551    Ok(report)
1552}
1553
1554/// Parses OKX WebSocket message payloads into Nautilus data structures.
1555///
1556/// # Errors
1557///
1558/// Returns an error if the payload cannot be deserialized or if downstream
1559/// parsing routines fail.
1560///
1561/// # Panics
1562///
1563/// Panics only in the case where `okx_channel_to_bar_spec(channel)` returns
1564/// `None` after a prior `is_some` check – an unreachable scenario indicating a
1565/// logic error.
1566#[allow(clippy::too_many_arguments)]
1567pub fn parse_ws_message_data(
1568    channel: &OKXWsChannel,
1569    data: serde_json::Value,
1570    instrument_id: &InstrumentId,
1571    price_precision: u8,
1572    size_precision: u8,
1573    ts_init: UnixNanos,
1574    funding_cache: &mut AHashMap<Ustr, (Ustr, u64)>,
1575    instruments_cache: &AHashMap<Ustr, InstrumentAny>,
1576) -> anyhow::Result<Option<NautilusWsMessage>> {
1577    match channel {
1578        OKXWsChannel::Instruments => {
1579            if let Ok(msg) = serde_json::from_value::<OKXInstrument>(data) {
1580                // Look up cached instrument to extract existing fees
1581                let (margin_init, margin_maint, maker_fee, taker_fee) =
1582                    instruments_cache.get(&Ustr::from(&msg.inst_id)).map_or(
1583                        (None, None, None, None),
1584                        extract_fees_from_cached_instrument,
1585                    );
1586
1587                match parse_instrument_any(
1588                    &msg,
1589                    margin_init,
1590                    margin_maint,
1591                    maker_fee,
1592                    taker_fee,
1593                    ts_init,
1594                )? {
1595                    Some(inst_any) => Ok(Some(NautilusWsMessage::Instrument(Box::new(inst_any)))),
1596                    None => {
1597                        tracing::warn!("Empty instrument payload: {:?}", msg);
1598                        Ok(None)
1599                    }
1600                }
1601            } else {
1602                anyhow::bail!("Failed to deserialize instrument payload")
1603            }
1604        }
1605        OKXWsChannel::BboTbt => {
1606            let data_vec = parse_quote_msg_vec(
1607                data,
1608                instrument_id,
1609                price_precision,
1610                size_precision,
1611                ts_init,
1612            )?;
1613            Ok(Some(NautilusWsMessage::Data(data_vec)))
1614        }
1615        OKXWsChannel::Tickers => {
1616            let data_vec = parse_ticker_msg_vec(
1617                data,
1618                instrument_id,
1619                price_precision,
1620                size_precision,
1621                ts_init,
1622            )?;
1623            Ok(Some(NautilusWsMessage::Data(data_vec)))
1624        }
1625        OKXWsChannel::Trades => {
1626            let data_vec = parse_trade_msg_vec(
1627                data,
1628                instrument_id,
1629                price_precision,
1630                size_precision,
1631                ts_init,
1632            )?;
1633            Ok(Some(NautilusWsMessage::Data(data_vec)))
1634        }
1635        OKXWsChannel::MarkPrice => {
1636            let data_vec = parse_mark_price_msg_vec(data, instrument_id, price_precision, ts_init)?;
1637            Ok(Some(NautilusWsMessage::Data(data_vec)))
1638        }
1639        OKXWsChannel::IndexTickers => {
1640            let data_vec =
1641                parse_index_price_msg_vec(data, instrument_id, price_precision, ts_init)?;
1642            Ok(Some(NautilusWsMessage::Data(data_vec)))
1643        }
1644        OKXWsChannel::FundingRate => {
1645            let data_vec = parse_funding_rate_msg_vec(data, instrument_id, ts_init, funding_cache)?;
1646            Ok(Some(NautilusWsMessage::FundingRates(data_vec)))
1647        }
1648        channel if okx_channel_to_bar_spec(channel).is_some() => {
1649            let bar_spec = okx_channel_to_bar_spec(channel).expect("bar_spec checked above");
1650            let data_vec = parse_candle_msg_vec(
1651                data,
1652                instrument_id,
1653                price_precision,
1654                size_precision,
1655                bar_spec,
1656                ts_init,
1657            )?;
1658            Ok(Some(NautilusWsMessage::Data(data_vec)))
1659        }
1660        OKXWsChannel::Books
1661        | OKXWsChannel::BooksTbt
1662        | OKXWsChannel::Books5
1663        | OKXWsChannel::Books50Tbt => {
1664            if let Ok(book_msgs) = serde_json::from_value::<Vec<OKXBookMsg>>(data) {
1665                let data_vec = parse_book10_msg_vec(
1666                    book_msgs,
1667                    instrument_id,
1668                    price_precision,
1669                    size_precision,
1670                    ts_init,
1671                )?;
1672                Ok(Some(NautilusWsMessage::Data(data_vec)))
1673            } else {
1674                anyhow::bail!("Failed to deserialize Books channel data as Vec<OKXBookMsg>")
1675            }
1676        }
1677        _ => {
1678            tracing::warn!("Unsupported channel for message parsing: {channel:?}");
1679            Ok(None)
1680        }
1681    }
1682}
1683
1684#[cfg(test)]
1685mod tests {
1686    use ahash::AHashMap;
1687    use nautilus_core::nanos::UnixNanos;
1688    use nautilus_model::{
1689        data::bar::BAR_SPEC_1_DAY_LAST,
1690        identifiers::{ClientOrderId, Symbol},
1691        instruments::CryptoPerpetual,
1692        types::Currency,
1693    };
1694    use rstest::rstest;
1695    use rust_decimal::Decimal;
1696    use rust_decimal_macros::dec;
1697    use ustr::Ustr;
1698
1699    use super::*;
1700    use crate::{
1701        OKXPositionSide,
1702        common::{
1703            enums::{OKXExecType, OKXInstrumentType, OKXOrderType, OKXSide, OKXTradeMode},
1704            parse::parse_account_state,
1705            testing::load_test_json,
1706        },
1707        http::models::OKXAccount,
1708        websocket::messages::{OKXWebSocketArg, OKXWsMessage},
1709    };
1710
1711    fn create_stub_instrument() -> CryptoPerpetual {
1712        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
1713        CryptoPerpetual::new(
1714            instrument_id,
1715            Symbol::from("BTC-USDT-SWAP"),
1716            Currency::BTC(),
1717            Currency::USDT(),
1718            Currency::USDT(),
1719            false,
1720            2,
1721            8,
1722            Price::from("0.01"),
1723            Quantity::from("0.00000001"),
1724            None,
1725            None,
1726            None,
1727            None,
1728            None,
1729            None,
1730            None,
1731            None,
1732            None,
1733            None,
1734            None,
1735            None,
1736            UnixNanos::default(),
1737            UnixNanos::default(),
1738        )
1739    }
1740
1741    fn create_stub_order_msg(
1742        fill_sz: &str,
1743        acc_fill_sz: Option<String>,
1744        order_id: &str,
1745        trade_id: &str,
1746    ) -> OKXOrderMsg {
1747        OKXOrderMsg {
1748            acc_fill_sz,
1749            avg_px: "50000.0".to_string(),
1750            c_time: 1746947317401,
1751            cancel_source: None,
1752            cancel_source_reason: None,
1753            category: OKXOrderCategory::Normal,
1754            ccy: Ustr::from("USDT"),
1755            cl_ord_id: "test_order_1".to_string(),
1756            algo_cl_ord_id: None,
1757            fee: Some("-1.0".to_string()),
1758            fee_ccy: Ustr::from("USDT"),
1759            fill_px: "50000.0".to_string(),
1760            fill_sz: fill_sz.to_string(),
1761            fill_time: 1746947317402,
1762            inst_id: Ustr::from("BTC-USDT-SWAP"),
1763            inst_type: OKXInstrumentType::Swap,
1764            lever: "2.0".to_string(),
1765            ord_id: Ustr::from(order_id),
1766            ord_type: OKXOrderType::Market,
1767            pnl: "0".to_string(),
1768            pos_side: OKXPositionSide::Long,
1769            px: String::new(),
1770            reduce_only: "false".to_string(),
1771            side: OKXSide::Buy,
1772            state: OKXOrderStatus::PartiallyFilled,
1773            exec_type: OKXExecType::Taker,
1774            sz: "0.03".to_string(),
1775            td_mode: OKXTradeMode::Isolated,
1776            tgt_ccy: None,
1777            trade_id: trade_id.to_string(),
1778            u_time: 1746947317402,
1779        }
1780    }
1781
1782    #[rstest]
1783    fn test_parse_books_snapshot() {
1784        let json_data = load_test_json("ws_books_snapshot.json");
1785        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1786        let (okx_books, action): (Vec<OKXBookMsg>, OKXBookAction) = match msg {
1787            OKXWsMessage::BookData { data, action, .. } => (data, action),
1788            _ => panic!("Expected a `BookData` variant"),
1789        };
1790
1791        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1792        let deltas = parse_book_msg(
1793            &okx_books[0],
1794            instrument_id,
1795            2,
1796            1,
1797            &action,
1798            UnixNanos::default(),
1799        )
1800        .unwrap();
1801
1802        assert_eq!(deltas.instrument_id, instrument_id);
1803        assert_eq!(deltas.deltas.len(), 16);
1804        assert_eq!(deltas.flags, 32);
1805        assert_eq!(deltas.sequence, 123456);
1806        assert_eq!(deltas.ts_event, UnixNanos::from(1597026383085000000));
1807        assert_eq!(deltas.ts_init, UnixNanos::default());
1808
1809        // Verify some individual deltas are parsed correctly
1810        assert!(!deltas.deltas.is_empty());
1811        // Snapshot should have both bid and ask deltas
1812        assert!(
1813            deltas.deltas.iter().any(|d| d.order.side == OrderSide::Buy),
1814            "Should have bid deltas"
1815        );
1816        assert!(
1817            deltas
1818                .deltas
1819                .iter()
1820                .any(|d| d.order.side == OrderSide::Sell),
1821            "Should have ask deltas"
1822        );
1823    }
1824
1825    #[rstest]
1826    fn test_parse_books_update() {
1827        let json_data = load_test_json("ws_books_update.json");
1828        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1829        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1830        let (okx_books, action): (Vec<OKXBookMsg>, OKXBookAction) = match msg {
1831            OKXWsMessage::BookData { data, action, .. } => (data, action),
1832            _ => panic!("Expected a `BookData` variant"),
1833        };
1834
1835        let deltas = parse_book_msg(
1836            &okx_books[0],
1837            instrument_id,
1838            2,
1839            1,
1840            &action,
1841            UnixNanos::default(),
1842        )
1843        .unwrap();
1844
1845        assert_eq!(deltas.instrument_id, instrument_id);
1846        assert_eq!(deltas.deltas.len(), 16);
1847        assert_eq!(deltas.flags, 0);
1848        assert_eq!(deltas.sequence, 123457);
1849        assert_eq!(deltas.ts_event, UnixNanos::from(1597026383085000000));
1850        assert_eq!(deltas.ts_init, UnixNanos::default());
1851
1852        // Verify some individual deltas are parsed correctly
1853        assert!(!deltas.deltas.is_empty());
1854        // Update should also have both bid and ask deltas
1855        assert!(
1856            deltas.deltas.iter().any(|d| d.order.side == OrderSide::Buy),
1857            "Should have bid deltas"
1858        );
1859        assert!(
1860            deltas
1861                .deltas
1862                .iter()
1863                .any(|d| d.order.side == OrderSide::Sell),
1864            "Should have ask deltas"
1865        );
1866    }
1867
1868    #[rstest]
1869    fn test_parse_tickers() {
1870        let json_data = load_test_json("ws_tickers.json");
1871        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1872        let okx_tickers: Vec<OKXTickerMsg> = match msg {
1873            OKXWsMessage::Data { data, .. } => serde_json::from_value(data).unwrap(),
1874            _ => panic!("Expected a `Data` variant"),
1875        };
1876
1877        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1878        let trade =
1879            parse_ticker_msg(&okx_tickers[0], instrument_id, 2, 1, UnixNanos::default()).unwrap();
1880
1881        assert_eq!(trade.instrument_id, InstrumentId::from("BTC-USDT.OKX"));
1882        assert_eq!(trade.bid_price, Price::from("8888.88"));
1883        assert_eq!(trade.ask_price, Price::from("9999.99"));
1884        assert_eq!(trade.bid_size, Quantity::from(5));
1885        assert_eq!(trade.ask_size, Quantity::from(11));
1886        assert_eq!(trade.ts_event, UnixNanos::from(1597026383085000000));
1887        assert_eq!(trade.ts_init, UnixNanos::default());
1888    }
1889
1890    #[rstest]
1891    fn test_parse_quotes() {
1892        let json_data = load_test_json("ws_bbo_tbt.json");
1893        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1894        let okx_quotes: Vec<OKXBookMsg> = match msg {
1895            OKXWsMessage::Data { data, .. } => serde_json::from_value(data).unwrap(),
1896            _ => panic!("Expected a `Data` variant"),
1897        };
1898        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1899
1900        let quote =
1901            parse_quote_msg(&okx_quotes[0], instrument_id, 2, 1, UnixNanos::default()).unwrap();
1902
1903        assert_eq!(quote.instrument_id, InstrumentId::from("BTC-USDT.OKX"));
1904        assert_eq!(quote.bid_price, Price::from("8476.97"));
1905        assert_eq!(quote.ask_price, Price::from("8476.98"));
1906        assert_eq!(quote.bid_size, Quantity::from(256));
1907        assert_eq!(quote.ask_size, Quantity::from(415));
1908        assert_eq!(quote.ts_event, UnixNanos::from(1597026383085000000));
1909        assert_eq!(quote.ts_init, UnixNanos::default());
1910    }
1911
1912    #[rstest]
1913    fn test_parse_trades() {
1914        let json_data = load_test_json("ws_trades.json");
1915        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1916        let okx_trades: Vec<OKXTradeMsg> = match msg {
1917            OKXWsMessage::Data { data, .. } => serde_json::from_value(data).unwrap(),
1918            _ => panic!("Expected a `Data` variant"),
1919        };
1920
1921        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1922        let trade =
1923            parse_trade_msg(&okx_trades[0], instrument_id, 1, 8, UnixNanos::default()).unwrap();
1924
1925        assert_eq!(trade.instrument_id, InstrumentId::from("BTC-USDT.OKX"));
1926        assert_eq!(trade.price, Price::from("42219.9"));
1927        assert_eq!(trade.size, Quantity::from("0.12060306"));
1928        assert_eq!(trade.aggressor_side, AggressorSide::Buyer);
1929        assert_eq!(trade.trade_id, TradeId::from("130639474"));
1930        assert_eq!(trade.ts_event, UnixNanos::from(1630048897897000000));
1931        assert_eq!(trade.ts_init, UnixNanos::default());
1932    }
1933
1934    #[rstest]
1935    fn test_parse_candle() {
1936        let json_data = load_test_json("ws_candle.json");
1937        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1938        let okx_candles: Vec<OKXCandleMsg> = match msg {
1939            OKXWsMessage::Data { data, .. } => serde_json::from_value(data).unwrap(),
1940            _ => panic!("Expected a `Data` variant"),
1941        };
1942
1943        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1944        let bar_type = BarType::new(
1945            instrument_id,
1946            BAR_SPEC_1_DAY_LAST,
1947            AggregationSource::External,
1948        );
1949        let bar = parse_candle_msg(&okx_candles[0], bar_type, 2, 0, UnixNanos::default()).unwrap();
1950
1951        assert_eq!(bar.bar_type, bar_type);
1952        assert_eq!(bar.open, Price::from("8533.02"));
1953        assert_eq!(bar.high, Price::from("8553.74"));
1954        assert_eq!(bar.low, Price::from("8527.17"));
1955        assert_eq!(bar.close, Price::from("8548.26"));
1956        assert_eq!(bar.volume, Quantity::from(45247));
1957        assert_eq!(bar.ts_event, UnixNanos::from(1597026383085000000));
1958        assert_eq!(bar.ts_init, UnixNanos::default());
1959    }
1960
1961    #[rstest]
1962    fn test_parse_funding_rate() {
1963        let json_data = load_test_json("ws_funding_rate.json");
1964        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1965
1966        let okx_funding_rates: Vec<crate::websocket::messages::OKXFundingRateMsg> = match msg {
1967            OKXWsMessage::Data { data, .. } => serde_json::from_value(data).unwrap(),
1968            _ => panic!("Expected a `Data` variant"),
1969        };
1970
1971        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
1972        let funding_rate =
1973            parse_funding_rate_msg(&okx_funding_rates[0], instrument_id, UnixNanos::default())
1974                .unwrap();
1975
1976        assert_eq!(funding_rate.instrument_id, instrument_id);
1977        assert_eq!(funding_rate.rate, dec!(0.0001));
1978        assert_eq!(
1979            funding_rate.next_funding_ns,
1980            Some(UnixNanos::from(1744590349506000000))
1981        );
1982        assert_eq!(funding_rate.ts_event, UnixNanos::from(1744590349506000000));
1983        assert_eq!(funding_rate.ts_init, UnixNanos::default());
1984    }
1985
1986    #[rstest]
1987    fn test_parse_book_vec() {
1988        let json_data = load_test_json("ws_books_snapshot.json");
1989        let event: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
1990        let (msgs, action): (Vec<OKXBookMsg>, OKXBookAction) = match event {
1991            OKXWsMessage::BookData { data, action, .. } => (data, action),
1992            _ => panic!("Expected BookData"),
1993        };
1994
1995        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
1996        let deltas_vec =
1997            parse_book_msg_vec(msgs, &instrument_id, 8, 1, action, UnixNanos::default()).unwrap();
1998
1999        assert_eq!(deltas_vec.len(), 1);
2000
2001        if let Data::Deltas(d) = &deltas_vec[0] {
2002            assert_eq!(d.sequence, 123456);
2003        } else {
2004            panic!("Expected Deltas");
2005        }
2006    }
2007
2008    #[rstest]
2009    fn test_parse_ticker_vec() {
2010        let json_data = load_test_json("ws_tickers.json");
2011        let event: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2012        let data_val: serde_json::Value = match event {
2013            OKXWsMessage::Data { data, .. } => data,
2014            _ => panic!("Expected Data"),
2015        };
2016
2017        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2018        let quotes_vec =
2019            parse_ticker_msg_vec(data_val, &instrument_id, 8, 1, UnixNanos::default()).unwrap();
2020
2021        assert_eq!(quotes_vec.len(), 1);
2022
2023        if let Data::Quote(q) = &quotes_vec[0] {
2024            assert_eq!(q.bid_price, Price::from("8888.88000000"));
2025            assert_eq!(q.ask_price, Price::from("9999.99"));
2026        } else {
2027            panic!("Expected Quote");
2028        }
2029    }
2030
2031    #[rstest]
2032    fn test_parse_trade_vec() {
2033        let json_data = load_test_json("ws_trades.json");
2034        let event: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2035        let data_val: serde_json::Value = match event {
2036            OKXWsMessage::Data { data, .. } => data,
2037            _ => panic!("Expected Data"),
2038        };
2039
2040        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2041        let trades_vec =
2042            parse_trade_msg_vec(data_val, &instrument_id, 8, 1, UnixNanos::default()).unwrap();
2043
2044        assert_eq!(trades_vec.len(), 1);
2045
2046        if let Data::Trade(t) = &trades_vec[0] {
2047            assert_eq!(t.trade_id, TradeId::new("130639474"));
2048        } else {
2049            panic!("Expected Trade");
2050        }
2051    }
2052
2053    #[rstest]
2054    fn test_parse_candle_vec() {
2055        let json_data = load_test_json("ws_candle.json");
2056        let event: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2057        let data_val: serde_json::Value = match event {
2058            OKXWsMessage::Data { data, .. } => data,
2059            _ => panic!("Expected Data"),
2060        };
2061
2062        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2063        let bars_vec = parse_candle_msg_vec(
2064            data_val,
2065            &instrument_id,
2066            2,
2067            1,
2068            BAR_SPEC_1_DAY_LAST,
2069            UnixNanos::default(),
2070        )
2071        .unwrap();
2072
2073        assert_eq!(bars_vec.len(), 1);
2074
2075        if let Data::Bar(b) = &bars_vec[0] {
2076            assert_eq!(b.open, Price::from("8533.02"));
2077        } else {
2078            panic!("Expected Bar");
2079        }
2080    }
2081
2082    #[rstest]
2083    fn test_parse_book_message() {
2084        let json_data = load_test_json("ws_bbo_tbt.json");
2085        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2086        let (okx_books, arg): (Vec<OKXBookMsg>, OKXWebSocketArg) = match msg {
2087            OKXWsMessage::Data { data, arg, .. } => (serde_json::from_value(data).unwrap(), arg),
2088            _ => panic!("Expected a `Data` variant"),
2089        };
2090
2091        assert_eq!(arg.channel, OKXWsChannel::BboTbt);
2092        assert_eq!(arg.inst_id.as_ref().unwrap(), &Ustr::from("BTC-USDT"));
2093        assert_eq!(arg.inst_type, None);
2094        assert_eq!(okx_books.len(), 1);
2095
2096        let book_msg = &okx_books[0];
2097
2098        // Check asks
2099        assert_eq!(book_msg.asks.len(), 1);
2100        let ask = &book_msg.asks[0];
2101        assert_eq!(ask.price, "8476.98");
2102        assert_eq!(ask.size, "415");
2103        assert_eq!(ask.liquidated_orders_count, "0");
2104        assert_eq!(ask.orders_count, "13");
2105
2106        // Check bids
2107        assert_eq!(book_msg.bids.len(), 1);
2108        let bid = &book_msg.bids[0];
2109        assert_eq!(bid.price, "8476.97");
2110        assert_eq!(bid.size, "256");
2111        assert_eq!(bid.liquidated_orders_count, "0");
2112        assert_eq!(bid.orders_count, "12");
2113        assert_eq!(book_msg.ts, 1597026383085);
2114        assert_eq!(book_msg.seq_id, 123456);
2115        assert_eq!(book_msg.checksum, None);
2116        assert_eq!(book_msg.prev_seq_id, None);
2117    }
2118
2119    #[rstest]
2120    fn test_parse_ws_account_message() {
2121        let json_data = load_test_json("ws_account.json");
2122        let msg: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2123
2124        let OKXWsMessage::Data { data, .. } = msg else {
2125            panic!("Expected OKXWsMessage::Data");
2126        };
2127
2128        let accounts: Vec<OKXAccount> = serde_json::from_value(data).unwrap();
2129
2130        assert_eq!(accounts.len(), 1);
2131        let account = &accounts[0];
2132
2133        assert_eq!(account.total_eq, "100.56089404807182");
2134        assert_eq!(account.details.len(), 3);
2135
2136        let usdt_detail = &account.details[0];
2137        assert_eq!(usdt_detail.ccy, "USDT");
2138        assert_eq!(usdt_detail.avail_bal, "100.52768569797846");
2139        assert_eq!(usdt_detail.cash_bal, "100.52768569797846");
2140
2141        let btc_detail = &account.details[1];
2142        assert_eq!(btc_detail.ccy, "BTC");
2143        assert_eq!(btc_detail.avail_bal, "0.0000000051");
2144
2145        let eth_detail = &account.details[2];
2146        assert_eq!(eth_detail.ccy, "ETH");
2147        assert_eq!(eth_detail.avail_bal, "0.000000185");
2148
2149        let account_id = AccountId::new("OKX-001");
2150        let ts_init = UnixNanos::default();
2151        let account_state = parse_account_state(account, account_id, ts_init);
2152
2153        assert!(account_state.is_ok());
2154        let state = account_state.unwrap();
2155        assert_eq!(state.account_id, account_id);
2156        assert_eq!(state.balances.len(), 3);
2157    }
2158
2159    #[rstest]
2160    fn test_parse_order_msg() {
2161        let json_data = load_test_json("ws_orders.json");
2162        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
2163
2164        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
2165
2166        let account_id = AccountId::new("OKX-001");
2167        let mut instruments = AHashMap::new();
2168
2169        // Create a mock instrument for testing
2170        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2171        let instrument = CryptoPerpetual::new(
2172            instrument_id,
2173            Symbol::from("BTC-USDT-SWAP"),
2174            Currency::BTC(),
2175            Currency::USDT(),
2176            Currency::USDT(),
2177            false, // is_inverse
2178            2,     // price_precision
2179            8,     // size_precision
2180            Price::from("0.01"),
2181            Quantity::from("0.00000001"),
2182            None, // multiplier
2183            None, // lot_size
2184            None, // max_quantity
2185            None, // min_quantity
2186            None, // max_notional
2187            None, // min_notional
2188            None, // max_price
2189            None, // min_price
2190            None, // margin_init
2191            None, // margin_maint
2192            None, // maker_fee
2193            None, // taker_fee
2194            UnixNanos::default(),
2195            UnixNanos::default(),
2196        );
2197
2198        instruments.insert(
2199            Ustr::from("BTC-USDT-SWAP"),
2200            InstrumentAny::CryptoPerpetual(instrument),
2201        );
2202
2203        let ts_init = UnixNanos::default();
2204        let fee_cache = AHashMap::new();
2205        let filled_qty_cache = AHashMap::new();
2206
2207        let result = parse_order_msg_vec(
2208            data,
2209            account_id,
2210            &instruments,
2211            &fee_cache,
2212            &filled_qty_cache,
2213            ts_init,
2214        );
2215
2216        assert!(result.is_ok());
2217        let order_reports = result.unwrap();
2218        assert_eq!(order_reports.len(), 1);
2219
2220        // Verify the parsed order report
2221        let report = &order_reports[0];
2222
2223        if let ExecutionReport::Fill(fill_report) = report {
2224            assert_eq!(fill_report.account_id, account_id);
2225            assert_eq!(fill_report.instrument_id, instrument_id);
2226            assert_eq!(
2227                fill_report.client_order_id,
2228                Some(ClientOrderId::new("001BTCUSDT20250106001"))
2229            );
2230            assert_eq!(
2231                fill_report.venue_order_id,
2232                VenueOrderId::new("2497956918703120384")
2233            );
2234            assert_eq!(fill_report.trade_id, TradeId::from("1518905529"));
2235            assert_eq!(fill_report.order_side, OrderSide::Buy);
2236            assert_eq!(fill_report.last_px, Price::from("103698.90"));
2237            assert_eq!(fill_report.last_qty, Quantity::from("0.03000000"));
2238            assert_eq!(fill_report.liquidity_side, LiquiditySide::Maker);
2239        } else {
2240            panic!("Expected Fill report for filled order");
2241        }
2242    }
2243
2244    #[rstest]
2245    fn test_parse_order_status_report() {
2246        let json_data = load_test_json("ws_orders.json");
2247        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
2248        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
2249        let order_msg = &data[0];
2250
2251        let account_id = AccountId::new("OKX-001");
2252        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2253        let instrument = CryptoPerpetual::new(
2254            instrument_id,
2255            Symbol::from("BTC-USDT-SWAP"),
2256            Currency::BTC(),
2257            Currency::USDT(),
2258            Currency::USDT(),
2259            false, // is_inverse
2260            2,     // price_precision
2261            8,     // size_precision
2262            Price::from("0.01"),
2263            Quantity::from("0.00000001"),
2264            None,
2265            None,
2266            None,
2267            None,
2268            None,
2269            None,
2270            None,
2271            None,
2272            None,
2273            None,
2274            None,
2275            None,
2276            UnixNanos::default(),
2277            UnixNanos::default(),
2278        );
2279
2280        let ts_init = UnixNanos::default();
2281
2282        let result = parse_order_status_report(
2283            order_msg,
2284            &InstrumentAny::CryptoPerpetual(instrument),
2285            account_id,
2286            ts_init,
2287        );
2288
2289        assert!(result.is_ok());
2290        let order_status_report = result.unwrap();
2291
2292        assert_eq!(order_status_report.account_id, account_id);
2293        assert_eq!(order_status_report.instrument_id, instrument_id);
2294        assert_eq!(
2295            order_status_report.client_order_id,
2296            Some(ClientOrderId::new("001BTCUSDT20250106001"))
2297        );
2298        assert_eq!(
2299            order_status_report.venue_order_id,
2300            VenueOrderId::new("2497956918703120384")
2301        );
2302        assert_eq!(order_status_report.order_side, OrderSide::Buy);
2303        assert_eq!(order_status_report.order_status, OrderStatus::Filled);
2304        assert_eq!(order_status_report.quantity, Quantity::from("0.03000000"));
2305        assert_eq!(order_status_report.filled_qty, Quantity::from("0.03000000"));
2306    }
2307
2308    #[rstest]
2309    fn test_parse_fill_report() {
2310        let json_data = load_test_json("ws_orders.json");
2311        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
2312        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
2313        let order_msg = &data[0];
2314
2315        let account_id = AccountId::new("OKX-001");
2316        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2317        let instrument = CryptoPerpetual::new(
2318            instrument_id,
2319            Symbol::from("BTC-USDT-SWAP"),
2320            Currency::BTC(),
2321            Currency::USDT(),
2322            Currency::USDT(),
2323            false, // is_inverse
2324            2,     // price_precision
2325            8,     // size_precision
2326            Price::from("0.01"),
2327            Quantity::from("0.00000001"),
2328            None,
2329            None,
2330            None,
2331            None,
2332            None,
2333            None,
2334            None,
2335            None,
2336            None,
2337            None,
2338            None,
2339            None,
2340            UnixNanos::default(),
2341            UnixNanos::default(),
2342        );
2343
2344        let ts_init = UnixNanos::default();
2345
2346        let result = parse_fill_report(
2347            order_msg,
2348            &InstrumentAny::CryptoPerpetual(instrument),
2349            account_id,
2350            None,
2351            None,
2352            ts_init,
2353        );
2354
2355        assert!(result.is_ok());
2356        let fill_report = result.unwrap();
2357
2358        assert_eq!(fill_report.account_id, account_id);
2359        assert_eq!(fill_report.instrument_id, instrument_id);
2360        assert_eq!(
2361            fill_report.client_order_id,
2362            Some(ClientOrderId::new("001BTCUSDT20250106001"))
2363        );
2364        assert_eq!(
2365            fill_report.venue_order_id,
2366            VenueOrderId::new("2497956918703120384")
2367        );
2368        assert_eq!(fill_report.trade_id, TradeId::from("1518905529"));
2369        assert_eq!(fill_report.order_side, OrderSide::Buy);
2370        assert_eq!(fill_report.last_px, Price::from("103698.90"));
2371        assert_eq!(fill_report.last_qty, Quantity::from("0.03000000"));
2372        assert_eq!(fill_report.liquidity_side, LiquiditySide::Maker);
2373    }
2374
2375    #[rstest]
2376    fn test_parse_book10_msg() {
2377        let json_data = load_test_json("ws_books_snapshot.json");
2378        let event: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2379        let msgs: Vec<OKXBookMsg> = match event {
2380            OKXWsMessage::BookData { data, .. } => data,
2381            _ => panic!("Expected BookData"),
2382        };
2383
2384        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2385        let depth10 =
2386            parse_book10_msg(&msgs[0], instrument_id, 2, 0, UnixNanos::default()).unwrap();
2387
2388        assert_eq!(depth10.instrument_id, instrument_id);
2389        assert_eq!(depth10.sequence, 123456);
2390        assert_eq!(depth10.ts_event, UnixNanos::from(1597026383085000000));
2391        assert_eq!(depth10.flags, RecordFlag::F_SNAPSHOT as u8);
2392
2393        // Check bid levels (available in test data: 8 levels)
2394        assert_eq!(depth10.bids[0].price, Price::from("8476.97"));
2395        assert_eq!(depth10.bids[0].size, Quantity::from("256"));
2396        assert_eq!(depth10.bids[0].side, OrderSide::Buy);
2397        assert_eq!(depth10.bid_counts[0], 12);
2398
2399        assert_eq!(depth10.bids[1].price, Price::from("8475.55"));
2400        assert_eq!(depth10.bids[1].size, Quantity::from("101"));
2401        assert_eq!(depth10.bid_counts[1], 1);
2402
2403        // Check that levels beyond available data are padded with empty orders
2404        assert_eq!(depth10.bids[8].price, Price::from("0"));
2405        assert_eq!(depth10.bids[8].size, Quantity::from("0"));
2406        assert_eq!(depth10.bid_counts[8], 0);
2407
2408        // Check ask levels (available in test data: 8 levels)
2409        assert_eq!(depth10.asks[0].price, Price::from("8476.98"));
2410        assert_eq!(depth10.asks[0].size, Quantity::from("415"));
2411        assert_eq!(depth10.asks[0].side, OrderSide::Sell);
2412        assert_eq!(depth10.ask_counts[0], 13);
2413
2414        assert_eq!(depth10.asks[1].price, Price::from("8477.00"));
2415        assert_eq!(depth10.asks[1].size, Quantity::from("7"));
2416        assert_eq!(depth10.ask_counts[1], 2);
2417
2418        // Check that levels beyond available data are padded with empty orders
2419        assert_eq!(depth10.asks[8].price, Price::from("0"));
2420        assert_eq!(depth10.asks[8].size, Quantity::from("0"));
2421        assert_eq!(depth10.ask_counts[8], 0);
2422    }
2423
2424    #[rstest]
2425    fn test_parse_book10_msg_vec() {
2426        let json_data = load_test_json("ws_books_snapshot.json");
2427        let event: OKXWsMessage = serde_json::from_str(&json_data).unwrap();
2428        let msgs: Vec<OKXBookMsg> = match event {
2429            OKXWsMessage::BookData { data, .. } => data,
2430            _ => panic!("Expected BookData"),
2431        };
2432
2433        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
2434        let depth10_vec =
2435            parse_book10_msg_vec(msgs, &instrument_id, 2, 0, UnixNanos::default()).unwrap();
2436
2437        assert_eq!(depth10_vec.len(), 1);
2438
2439        if let Data::Depth10(d) = &depth10_vec[0] {
2440            assert_eq!(d.instrument_id, instrument_id);
2441            assert_eq!(d.sequence, 123456);
2442            assert_eq!(d.bids[0].price, Price::from("8476.97"));
2443            assert_eq!(d.asks[0].price, Price::from("8476.98"));
2444        } else {
2445            panic!("Expected Depth10");
2446        }
2447    }
2448
2449    #[rstest]
2450    fn test_parse_fill_report_with_fee_cache() {
2451        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2452        let instrument = CryptoPerpetual::new(
2453            instrument_id,
2454            Symbol::from("BTC-USDT-SWAP"),
2455            Currency::BTC(),
2456            Currency::USDT(),
2457            Currency::USDT(),
2458            false, // is_inverse
2459            2,     // price_precision
2460            8,     // size_precision
2461            Price::from("0.01"),
2462            Quantity::from("0.00000001"),
2463            None, // multiplier
2464            None, // lot_size
2465            None, // max_quantity
2466            None, // min_quantity
2467            None, // max_notional
2468            None, // min_notional
2469            None, // max_price
2470            None, // min_price
2471            None, // margin_init
2472            None, // margin_maint
2473            None, // maker_fee
2474            None, // taker_fee
2475            UnixNanos::default(),
2476            UnixNanos::default(),
2477        );
2478
2479        let account_id = AccountId::new("OKX-001");
2480        let ts_init = UnixNanos::default();
2481
2482        // First fill: 0.01 BTC out of 0.03 BTC total (1/3)
2483        let order_msg_1 = OKXOrderMsg {
2484            acc_fill_sz: Some("0.01".to_string()),
2485            avg_px: "50000.0".to_string(),
2486            c_time: 1746947317401,
2487            cancel_source: None,
2488            cancel_source_reason: None,
2489            category: OKXOrderCategory::Normal,
2490            ccy: Ustr::from("USDT"),
2491            cl_ord_id: "test_order_1".to_string(),
2492            algo_cl_ord_id: None,
2493            fee: Some("-1.0".to_string()), // Total fee so far
2494            fee_ccy: Ustr::from("USDT"),
2495            fill_px: "50000.0".to_string(),
2496            fill_sz: "0.01".to_string(),
2497            fill_time: 1746947317402,
2498            inst_id: Ustr::from("BTC-USDT-SWAP"),
2499            inst_type: OKXInstrumentType::Swap,
2500            lever: "2.0".to_string(),
2501            ord_id: Ustr::from("1234567890"),
2502            ord_type: OKXOrderType::Market,
2503            pnl: "0".to_string(),
2504            pos_side: OKXPositionSide::Long,
2505            px: String::new(),
2506            reduce_only: "false".to_string(),
2507            side: OKXSide::Buy,
2508            state: OKXOrderStatus::PartiallyFilled,
2509            exec_type: OKXExecType::Maker,
2510            sz: "0.03".to_string(), // Total order size
2511            td_mode: OKXTradeMode::Isolated,
2512            tgt_ccy: None,
2513            trade_id: "trade_1".to_string(),
2514            u_time: 1746947317402,
2515        };
2516
2517        let fill_report_1 = parse_fill_report(
2518            &order_msg_1,
2519            &InstrumentAny::CryptoPerpetual(instrument),
2520            account_id,
2521            None,
2522            None,
2523            ts_init,
2524        )
2525        .unwrap();
2526
2527        // First fill should get the full fee since there's no previous fee
2528        assert_eq!(fill_report_1.commission, Money::new(1.0, Currency::USDT()));
2529
2530        // Second fill: 0.02 BTC more, now 0.03 BTC total (completely filled)
2531        let order_msg_2 = OKXOrderMsg {
2532            acc_fill_sz: Some("0.03".to_string()),
2533            avg_px: "50000.0".to_string(),
2534            c_time: 1746947317401,
2535            cancel_source: None,
2536            cancel_source_reason: None,
2537            category: OKXOrderCategory::Normal,
2538            ccy: Ustr::from("USDT"),
2539            cl_ord_id: "test_order_1".to_string(),
2540            algo_cl_ord_id: None,
2541            fee: Some("-3.0".to_string()), // Same total fee
2542            fee_ccy: Ustr::from("USDT"),
2543            fill_px: "50000.0".to_string(),
2544            fill_sz: "0.02".to_string(),
2545            fill_time: 1746947317403,
2546            inst_id: Ustr::from("BTC-USDT-SWAP"),
2547            inst_type: OKXInstrumentType::Swap,
2548            lever: "2.0".to_string(),
2549            ord_id: Ustr::from("1234567890"),
2550            ord_type: OKXOrderType::Market,
2551            pnl: "0".to_string(),
2552            pos_side: OKXPositionSide::Long,
2553            px: String::new(),
2554            reduce_only: "false".to_string(),
2555            side: OKXSide::Buy,
2556            state: OKXOrderStatus::Filled,
2557            exec_type: OKXExecType::Maker,
2558            sz: "0.03".to_string(), // Same total order size
2559            td_mode: OKXTradeMode::Isolated,
2560            tgt_ccy: None,
2561            trade_id: "trade_2".to_string(),
2562            u_time: 1746947317403,
2563        };
2564
2565        let fill_report_2 = parse_fill_report(
2566            &order_msg_2,
2567            &InstrumentAny::CryptoPerpetual(instrument),
2568            account_id,
2569            Some(fill_report_1.commission),
2570            Some(fill_report_1.last_qty),
2571            ts_init,
2572        )
2573        .unwrap();
2574
2575        // Second fill should get total_fee - previous_fee = 3.0 - 1.0 = 2.0
2576        assert_eq!(fill_report_2.commission, Money::new(2.0, Currency::USDT()));
2577
2578        // Test passed - fee was correctly split proportionally
2579    }
2580
2581    #[rstest]
2582    fn test_parse_fill_report_with_maker_rebates() {
2583        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2584        let instrument = CryptoPerpetual::new(
2585            instrument_id,
2586            Symbol::from("BTC-USDT-SWAP"),
2587            Currency::BTC(),
2588            Currency::USDT(),
2589            Currency::USDT(),
2590            false,
2591            2,
2592            8,
2593            Price::from("0.01"),
2594            Quantity::from("0.00000001"),
2595            None,
2596            None,
2597            None,
2598            None,
2599            None,
2600            None,
2601            None,
2602            None,
2603            None,
2604            None,
2605            None,
2606            None,
2607            UnixNanos::default(),
2608            UnixNanos::default(),
2609        );
2610
2611        let account_id = AccountId::new("OKX-001");
2612        let ts_init = UnixNanos::default();
2613
2614        // First fill: maker rebate of $0.5 (OKX sends as "0.5", parse_fee makes it -0.5)
2615        let order_msg_1 = OKXOrderMsg {
2616            acc_fill_sz: Some("0.01".to_string()),
2617            avg_px: "50000.0".to_string(),
2618            c_time: 1746947317401,
2619            cancel_source: None,
2620            cancel_source_reason: None,
2621            category: OKXOrderCategory::Normal,
2622            ccy: Ustr::from("USDT"),
2623            cl_ord_id: "test_order_rebate".to_string(),
2624            algo_cl_ord_id: None,
2625            fee: Some("0.5".to_string()), // Rebate: positive value from OKX
2626            fee_ccy: Ustr::from("USDT"),
2627            fill_px: "50000.0".to_string(),
2628            fill_sz: "0.01".to_string(),
2629            fill_time: 1746947317402,
2630            inst_id: Ustr::from("BTC-USDT-SWAP"),
2631            inst_type: OKXInstrumentType::Swap,
2632            lever: "2.0".to_string(),
2633            ord_id: Ustr::from("rebate_order_123"),
2634            ord_type: OKXOrderType::Market,
2635            pnl: "0".to_string(),
2636            pos_side: OKXPositionSide::Long,
2637            px: String::new(),
2638            reduce_only: "false".to_string(),
2639            side: OKXSide::Buy,
2640            state: OKXOrderStatus::PartiallyFilled,
2641            exec_type: OKXExecType::Maker,
2642            sz: "0.02".to_string(),
2643            td_mode: OKXTradeMode::Isolated,
2644            tgt_ccy: None,
2645            trade_id: "trade_rebate_1".to_string(),
2646            u_time: 1746947317402,
2647        };
2648
2649        let fill_report_1 = parse_fill_report(
2650            &order_msg_1,
2651            &InstrumentAny::CryptoPerpetual(instrument),
2652            account_id,
2653            None,
2654            None,
2655            ts_init,
2656        )
2657        .unwrap();
2658
2659        // First fill gets the full rebate (negative commission)
2660        assert_eq!(fill_report_1.commission, Money::new(-0.5, Currency::USDT()));
2661
2662        // Second fill: another maker rebate of $0.3, cumulative now $0.8
2663        let order_msg_2 = OKXOrderMsg {
2664            acc_fill_sz: Some("0.02".to_string()),
2665            avg_px: "50000.0".to_string(),
2666            c_time: 1746947317401,
2667            cancel_source: None,
2668            cancel_source_reason: None,
2669            category: OKXOrderCategory::Normal,
2670            ccy: Ustr::from("USDT"),
2671            cl_ord_id: "test_order_rebate".to_string(),
2672            algo_cl_ord_id: None,
2673            fee: Some("0.8".to_string()), // Cumulative rebate
2674            fee_ccy: Ustr::from("USDT"),
2675            fill_px: "50000.0".to_string(),
2676            fill_sz: "0.01".to_string(),
2677            fill_time: 1746947317403,
2678            inst_id: Ustr::from("BTC-USDT-SWAP"),
2679            inst_type: OKXInstrumentType::Swap,
2680            lever: "2.0".to_string(),
2681            ord_id: Ustr::from("rebate_order_123"),
2682            ord_type: OKXOrderType::Market,
2683            pnl: "0".to_string(),
2684            pos_side: OKXPositionSide::Long,
2685            px: String::new(),
2686            reduce_only: "false".to_string(),
2687            side: OKXSide::Buy,
2688            state: OKXOrderStatus::Filled,
2689            exec_type: OKXExecType::Maker,
2690            sz: "0.02".to_string(),
2691            td_mode: OKXTradeMode::Isolated,
2692            tgt_ccy: None,
2693            trade_id: "trade_rebate_2".to_string(),
2694            u_time: 1746947317403,
2695        };
2696
2697        let fill_report_2 = parse_fill_report(
2698            &order_msg_2,
2699            &InstrumentAny::CryptoPerpetual(instrument),
2700            account_id,
2701            Some(fill_report_1.commission),
2702            Some(fill_report_1.last_qty),
2703            ts_init,
2704        )
2705        .unwrap();
2706
2707        // Second fill: incremental = -0.8 - (-0.5) = -0.3
2708        assert_eq!(fill_report_2.commission, Money::new(-0.3, Currency::USDT()));
2709    }
2710
2711    #[rstest]
2712    fn test_parse_fill_report_rebate_to_charge_transition() {
2713        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2714        let instrument = CryptoPerpetual::new(
2715            instrument_id,
2716            Symbol::from("BTC-USDT-SWAP"),
2717            Currency::BTC(),
2718            Currency::USDT(),
2719            Currency::USDT(),
2720            false,
2721            2,
2722            8,
2723            Price::from("0.01"),
2724            Quantity::from("0.00000001"),
2725            None,
2726            None,
2727            None,
2728            None,
2729            None,
2730            None,
2731            None,
2732            None,
2733            None,
2734            None,
2735            None,
2736            None,
2737            UnixNanos::default(),
2738            UnixNanos::default(),
2739        );
2740
2741        let account_id = AccountId::new("OKX-001");
2742        let ts_init = UnixNanos::default();
2743
2744        // First fill: maker rebate of $1.0
2745        let order_msg_1 = OKXOrderMsg {
2746            acc_fill_sz: Some("0.01".to_string()),
2747            avg_px: "50000.0".to_string(),
2748            c_time: 1746947317401,
2749            cancel_source: None,
2750            cancel_source_reason: None,
2751            category: OKXOrderCategory::Normal,
2752            ccy: Ustr::from("USDT"),
2753            cl_ord_id: "test_order_transition".to_string(),
2754            algo_cl_ord_id: None,
2755            fee: Some("1.0".to_string()), // Rebate from OKX
2756            fee_ccy: Ustr::from("USDT"),
2757            fill_px: "50000.0".to_string(),
2758            fill_sz: "0.01".to_string(),
2759            fill_time: 1746947317402,
2760            inst_id: Ustr::from("BTC-USDT-SWAP"),
2761            inst_type: OKXInstrumentType::Swap,
2762            lever: "2.0".to_string(),
2763            ord_id: Ustr::from("transition_order_456"),
2764            ord_type: OKXOrderType::Market,
2765            pnl: "0".to_string(),
2766            pos_side: OKXPositionSide::Long,
2767            px: String::new(),
2768            reduce_only: "false".to_string(),
2769            side: OKXSide::Buy,
2770            state: OKXOrderStatus::PartiallyFilled,
2771            exec_type: OKXExecType::Maker,
2772            sz: "0.02".to_string(),
2773            td_mode: OKXTradeMode::Isolated,
2774            tgt_ccy: None,
2775            trade_id: "trade_transition_1".to_string(),
2776            u_time: 1746947317402,
2777        };
2778
2779        let fill_report_1 = parse_fill_report(
2780            &order_msg_1,
2781            &InstrumentAny::CryptoPerpetual(instrument),
2782            account_id,
2783            None,
2784            None,
2785            ts_init,
2786        )
2787        .unwrap();
2788
2789        // First fill gets rebate (negative)
2790        assert_eq!(fill_report_1.commission, Money::new(-1.0, Currency::USDT()));
2791
2792        // Second fill: taker charge of $5.0, net cumulative is now $2.0 charge
2793        // This is the edge case: incremental = 2.0 - (-1.0) = 3.0, which exceeds total (2.0)
2794        // But it's legitimate, not corruption
2795        let order_msg_2 = OKXOrderMsg {
2796            acc_fill_sz: Some("0.02".to_string()),
2797            avg_px: "50000.0".to_string(),
2798            c_time: 1746947317401,
2799            cancel_source: None,
2800            cancel_source_reason: None,
2801            category: OKXOrderCategory::Normal,
2802            ccy: Ustr::from("USDT"),
2803            cl_ord_id: "test_order_transition".to_string(),
2804            algo_cl_ord_id: None,
2805            fee: Some("-2.0".to_string()), // Now a charge (negative from OKX)
2806            fee_ccy: Ustr::from("USDT"),
2807            fill_px: "50000.0".to_string(),
2808            fill_sz: "0.01".to_string(),
2809            fill_time: 1746947317403,
2810            inst_id: Ustr::from("BTC-USDT-SWAP"),
2811            inst_type: OKXInstrumentType::Swap,
2812            lever: "2.0".to_string(),
2813            ord_id: Ustr::from("transition_order_456"),
2814            ord_type: OKXOrderType::Market,
2815            pnl: "0".to_string(),
2816            pos_side: OKXPositionSide::Long,
2817            px: String::new(),
2818            reduce_only: "false".to_string(),
2819            side: OKXSide::Buy,
2820            state: OKXOrderStatus::Filled,
2821            exec_type: OKXExecType::Taker,
2822            sz: "0.02".to_string(),
2823            td_mode: OKXTradeMode::Isolated,
2824            tgt_ccy: None,
2825            trade_id: "trade_transition_2".to_string(),
2826            u_time: 1746947317403,
2827        };
2828
2829        let fill_report_2 = parse_fill_report(
2830            &order_msg_2,
2831            &InstrumentAny::CryptoPerpetual(instrument),
2832            account_id,
2833            Some(fill_report_1.commission),
2834            Some(fill_report_1.last_qty),
2835            ts_init,
2836        )
2837        .unwrap();
2838
2839        // Second fill: incremental = 2.0 - (-1.0) = 3.0
2840        // This should NOT trigger corruption detection because previous was negative
2841        assert_eq!(fill_report_2.commission, Money::new(3.0, Currency::USDT()));
2842    }
2843
2844    #[rstest]
2845    fn test_parse_fill_report_negative_incremental() {
2846        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
2847        let instrument = CryptoPerpetual::new(
2848            instrument_id,
2849            Symbol::from("BTC-USDT-SWAP"),
2850            Currency::BTC(),
2851            Currency::USDT(),
2852            Currency::USDT(),
2853            false,
2854            2,
2855            8,
2856            Price::from("0.01"),
2857            Quantity::from("0.00000001"),
2858            None,
2859            None,
2860            None,
2861            None,
2862            None,
2863            None,
2864            None,
2865            None,
2866            None,
2867            None,
2868            None,
2869            None,
2870            UnixNanos::default(),
2871            UnixNanos::default(),
2872        );
2873
2874        let account_id = AccountId::new("OKX-001");
2875        let ts_init = UnixNanos::default();
2876
2877        // First fill: charge of $2.0
2878        let order_msg_1 = OKXOrderMsg {
2879            acc_fill_sz: Some("0.01".to_string()),
2880            avg_px: "50000.0".to_string(),
2881            c_time: 1746947317401,
2882            cancel_source: None,
2883            cancel_source_reason: None,
2884            category: OKXOrderCategory::Normal,
2885            ccy: Ustr::from("USDT"),
2886            cl_ord_id: "test_order_neg_inc".to_string(),
2887            algo_cl_ord_id: None,
2888            fee: Some("-2.0".to_string()),
2889            fee_ccy: Ustr::from("USDT"),
2890            fill_px: "50000.0".to_string(),
2891            fill_sz: "0.01".to_string(),
2892            fill_time: 1746947317402,
2893            inst_id: Ustr::from("BTC-USDT-SWAP"),
2894            inst_type: OKXInstrumentType::Swap,
2895            lever: "2.0".to_string(),
2896            ord_id: Ustr::from("neg_inc_order_789"),
2897            ord_type: OKXOrderType::Market,
2898            pnl: "0".to_string(),
2899            pos_side: OKXPositionSide::Long,
2900            px: String::new(),
2901            reduce_only: "false".to_string(),
2902            side: OKXSide::Buy,
2903            state: OKXOrderStatus::PartiallyFilled,
2904            exec_type: OKXExecType::Taker,
2905            sz: "0.02".to_string(),
2906            td_mode: OKXTradeMode::Isolated,
2907            tgt_ccy: None,
2908            trade_id: "trade_neg_inc_1".to_string(),
2909            u_time: 1746947317402,
2910        };
2911
2912        let fill_report_1 = parse_fill_report(
2913            &order_msg_1,
2914            &InstrumentAny::CryptoPerpetual(instrument),
2915            account_id,
2916            None,
2917            None,
2918            ts_init,
2919        )
2920        .unwrap();
2921
2922        assert_eq!(fill_report_1.commission, Money::new(2.0, Currency::USDT()));
2923
2924        // Second fill: charge reduced to $1.5 total (refund or maker rebate on this fill)
2925        // Incremental = 1.5 - 2.0 = -0.5 (negative incremental triggers debug log)
2926        let order_msg_2 = OKXOrderMsg {
2927            acc_fill_sz: Some("0.02".to_string()),
2928            avg_px: "50000.0".to_string(),
2929            c_time: 1746947317401,
2930            cancel_source: None,
2931            cancel_source_reason: None,
2932            category: OKXOrderCategory::Normal,
2933            ccy: Ustr::from("USDT"),
2934            cl_ord_id: "test_order_neg_inc".to_string(),
2935            algo_cl_ord_id: None,
2936            fee: Some("-1.5".to_string()), // Total reduced
2937            fee_ccy: Ustr::from("USDT"),
2938            fill_px: "50000.0".to_string(),
2939            fill_sz: "0.01".to_string(),
2940            fill_time: 1746947317403,
2941            inst_id: Ustr::from("BTC-USDT-SWAP"),
2942            inst_type: OKXInstrumentType::Swap,
2943            lever: "2.0".to_string(),
2944            ord_id: Ustr::from("neg_inc_order_789"),
2945            ord_type: OKXOrderType::Market,
2946            pnl: "0".to_string(),
2947            pos_side: OKXPositionSide::Long,
2948            px: String::new(),
2949            reduce_only: "false".to_string(),
2950            side: OKXSide::Buy,
2951            state: OKXOrderStatus::Filled,
2952            exec_type: OKXExecType::Maker,
2953            sz: "0.02".to_string(),
2954            td_mode: OKXTradeMode::Isolated,
2955            tgt_ccy: None,
2956            trade_id: "trade_neg_inc_2".to_string(),
2957            u_time: 1746947317403,
2958        };
2959
2960        let fill_report_2 = parse_fill_report(
2961            &order_msg_2,
2962            &InstrumentAny::CryptoPerpetual(instrument),
2963            account_id,
2964            Some(fill_report_1.commission),
2965            Some(fill_report_1.last_qty),
2966            ts_init,
2967        )
2968        .unwrap();
2969
2970        // Incremental is negative: 1.5 - 2.0 = -0.5
2971        assert_eq!(fill_report_2.commission, Money::new(-0.5, Currency::USDT()));
2972    }
2973
2974    #[rstest]
2975    fn test_parse_fill_report_empty_fill_sz_first_fill() {
2976        let instrument = create_stub_instrument();
2977        let account_id = AccountId::new("OKX-001");
2978        let ts_init = UnixNanos::default();
2979
2980        let order_msg =
2981            create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "trade_1");
2982
2983        let fill_report = parse_fill_report(
2984            &order_msg,
2985            &InstrumentAny::CryptoPerpetual(instrument),
2986            account_id,
2987            None,
2988            None,
2989            ts_init,
2990        )
2991        .unwrap();
2992
2993        assert_eq!(fill_report.last_qty, Quantity::from("0.01"));
2994    }
2995
2996    #[rstest]
2997    fn test_parse_fill_report_empty_fill_sz_subsequent_fills() {
2998        let instrument = create_stub_instrument();
2999        let account_id = AccountId::new("OKX-001");
3000        let ts_init = UnixNanos::default();
3001
3002        let order_msg_1 =
3003            create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "trade_1");
3004
3005        let fill_report_1 = parse_fill_report(
3006            &order_msg_1,
3007            &InstrumentAny::CryptoPerpetual(instrument),
3008            account_id,
3009            None,
3010            None,
3011            ts_init,
3012        )
3013        .unwrap();
3014
3015        assert_eq!(fill_report_1.last_qty, Quantity::from("0.01"));
3016
3017        let order_msg_2 =
3018            create_stub_order_msg("", Some("0.03".to_string()), "1234567890", "trade_2");
3019
3020        let fill_report_2 = parse_fill_report(
3021            &order_msg_2,
3022            &InstrumentAny::CryptoPerpetual(instrument),
3023            account_id,
3024            Some(fill_report_1.commission),
3025            Some(fill_report_1.last_qty),
3026            ts_init,
3027        )
3028        .unwrap();
3029
3030        assert_eq!(fill_report_2.last_qty, Quantity::from("0.02"));
3031    }
3032
3033    #[rstest]
3034    fn test_parse_fill_report_error_both_empty() {
3035        let instrument = create_stub_instrument();
3036        let account_id = AccountId::new("OKX-001");
3037        let ts_init = UnixNanos::default();
3038
3039        let order_msg = create_stub_order_msg("", Some(String::new()), "1234567890", "trade_1");
3040
3041        let result = parse_fill_report(
3042            &order_msg,
3043            &InstrumentAny::CryptoPerpetual(instrument),
3044            account_id,
3045            None,
3046            None,
3047            ts_init,
3048        );
3049
3050        assert!(result.is_err());
3051        let err_msg = result.unwrap_err().to_string();
3052        assert!(err_msg.contains("Cannot determine fill quantity"));
3053        assert!(err_msg.contains("empty/zero"));
3054    }
3055
3056    #[rstest]
3057    fn test_parse_fill_report_error_acc_fill_sz_none() {
3058        let instrument = create_stub_instrument();
3059        let account_id = AccountId::new("OKX-001");
3060        let ts_init = UnixNanos::default();
3061
3062        let order_msg = create_stub_order_msg("", None, "1234567890", "trade_1");
3063
3064        let result = parse_fill_report(
3065            &order_msg,
3066            &InstrumentAny::CryptoPerpetual(instrument),
3067            account_id,
3068            None,
3069            None,
3070            ts_init,
3071        );
3072
3073        assert!(result.is_err());
3074        let err_msg = result.unwrap_err().to_string();
3075        assert!(err_msg.contains("Cannot determine fill quantity"));
3076        assert!(err_msg.contains("acc_fill_sz is None"));
3077    }
3078
3079    #[rstest]
3080    fn test_parse_order_msg_acc_fill_sz_only_update() {
3081        // Test that we emit fill reports when OKX only updates acc_fill_sz without fill_sz or trade_id
3082        let instrument = create_stub_instrument();
3083        let account_id = AccountId::new("OKX-001");
3084        let ts_init = UnixNanos::default();
3085
3086        let mut instruments = AHashMap::new();
3087        instruments.insert(
3088            Ustr::from("BTC-USDT-SWAP"),
3089            InstrumentAny::CryptoPerpetual(instrument),
3090        );
3091
3092        let fee_cache = AHashMap::new();
3093        let mut filled_qty_cache = AHashMap::new();
3094
3095        // First update: acc_fill_sz = 0.01, no fill_sz, no trade_id
3096        let msg_1 = create_stub_order_msg("", Some("0.01".to_string()), "1234567890", "");
3097
3098        let report_1 = parse_order_msg(
3099            &msg_1,
3100            account_id,
3101            &instruments,
3102            &fee_cache,
3103            &filled_qty_cache,
3104            ts_init,
3105        )
3106        .unwrap();
3107
3108        // Should generate a fill report (not a status report)
3109        assert!(matches!(report_1, ExecutionReport::Fill(_)));
3110        if let ExecutionReport::Fill(fill) = &report_1 {
3111            assert_eq!(fill.last_qty, Quantity::from("0.01"));
3112        }
3113
3114        // Update cache
3115        filled_qty_cache.insert(Ustr::from("1234567890"), Quantity::from("0.01"));
3116
3117        // Second update: acc_fill_sz increased to 0.03, still no fill_sz or trade_id
3118        let msg_2 = create_stub_order_msg("", Some("0.03".to_string()), "1234567890", "");
3119
3120        let report_2 = parse_order_msg(
3121            &msg_2,
3122            account_id,
3123            &instruments,
3124            &fee_cache,
3125            &filled_qty_cache,
3126            ts_init,
3127        )
3128        .unwrap();
3129
3130        // Should still generate a fill report for the incremental 0.02
3131        assert!(matches!(report_2, ExecutionReport::Fill(_)));
3132        if let ExecutionReport::Fill(fill) = &report_2 {
3133            assert_eq!(fill.last_qty, Quantity::from("0.02"));
3134        }
3135    }
3136
3137    #[rstest]
3138    fn test_parse_book10_msg_partial_levels() {
3139        // Test with fewer than 10 levels - should pad with empty orders
3140        let book_msg = OKXBookMsg {
3141            asks: vec![
3142                OrderBookEntry {
3143                    price: "8476.98".to_string(),
3144                    size: "415".to_string(),
3145                    liquidated_orders_count: "0".to_string(),
3146                    orders_count: "13".to_string(),
3147                },
3148                OrderBookEntry {
3149                    price: "8477.00".to_string(),
3150                    size: "7".to_string(),
3151                    liquidated_orders_count: "0".to_string(),
3152                    orders_count: "2".to_string(),
3153                },
3154            ],
3155            bids: vec![OrderBookEntry {
3156                price: "8476.97".to_string(),
3157                size: "256".to_string(),
3158                liquidated_orders_count: "0".to_string(),
3159                orders_count: "12".to_string(),
3160            }],
3161            ts: 1597026383085,
3162            checksum: None,
3163            prev_seq_id: None,
3164            seq_id: 123456,
3165        };
3166
3167        let instrument_id = InstrumentId::from("BTC-USDT.OKX");
3168        let depth10 =
3169            parse_book10_msg(&book_msg, instrument_id, 2, 0, UnixNanos::default()).unwrap();
3170
3171        // Check that first levels have data
3172        assert_eq!(depth10.bids[0].price, Price::from("8476.97"));
3173        assert_eq!(depth10.bids[0].size, Quantity::from("256"));
3174        assert_eq!(depth10.bid_counts[0], 12);
3175
3176        // Check that remaining levels are padded with default (empty) orders
3177        assert_eq!(depth10.bids[1].price, Price::from("0"));
3178        assert_eq!(depth10.bids[1].size, Quantity::from("0"));
3179        assert_eq!(depth10.bid_counts[1], 0);
3180
3181        // Check asks
3182        assert_eq!(depth10.asks[0].price, Price::from("8476.98"));
3183        assert_eq!(depth10.asks[1].price, Price::from("8477.00"));
3184        assert_eq!(depth10.asks[2].price, Price::from("0")); // padded with empty
3185    }
3186
3187    #[rstest]
3188    fn test_parse_algo_order_msg_stop_market() {
3189        let json_data = load_test_json("ws_orders_algo.json");
3190        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3191        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3192
3193        // Test first algo order (stop market sell)
3194        let msg = &data[0];
3195        assert_eq!(msg.algo_id, "706620792746729472");
3196        assert_eq!(msg.algo_cl_ord_id, "STOP001BTCUSDT20250120");
3197        assert_eq!(msg.state, OKXOrderStatus::Live);
3198        assert_eq!(msg.ord_px, "-1"); // Market order indicator
3199
3200        let account_id = AccountId::new("OKX-001");
3201        let mut instruments = AHashMap::new();
3202
3203        // Create mock instrument
3204        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3205        let instrument = CryptoPerpetual::new(
3206            instrument_id,
3207            Symbol::from("BTC-USDT-SWAP"),
3208            Currency::BTC(),
3209            Currency::USDT(),
3210            Currency::USDT(),
3211            false, // is_inverse
3212            2,     // price_precision
3213            8,     // size_precision
3214            Price::from("0.01"),
3215            Quantity::from("0.00000001"),
3216            None,
3217            None,
3218            None,
3219            None,
3220            None,
3221            None,
3222            None,
3223            None,
3224            None,
3225            None,
3226            None,
3227            None,
3228            0.into(), // ts_event
3229            0.into(), // ts_init
3230        );
3231        instruments.insert(
3232            Ustr::from("BTC-USDT-SWAP"),
3233            InstrumentAny::CryptoPerpetual(instrument),
3234        );
3235
3236        let result =
3237            parse_algo_order_msg(msg.clone(), account_id, &instruments, UnixNanos::default());
3238
3239        assert!(result.is_ok());
3240        let report = result.unwrap();
3241
3242        if let ExecutionReport::Order(status_report) = report {
3243            assert_eq!(status_report.order_type, OrderType::StopMarket);
3244            assert_eq!(status_report.order_side, OrderSide::Sell);
3245            assert_eq!(status_report.quantity, Quantity::from("0.01000000"));
3246            assert_eq!(status_report.trigger_price, Some(Price::from("95000.00")));
3247            assert_eq!(status_report.trigger_type, Some(TriggerType::LastPrice));
3248            assert_eq!(status_report.price, None); // No limit price for market orders
3249        } else {
3250            panic!("Expected Order report");
3251        }
3252    }
3253
3254    #[rstest]
3255    fn test_parse_algo_order_msg_stop_limit() {
3256        let json_data = load_test_json("ws_orders_algo.json");
3257        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3258        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3259
3260        // Test second algo order (stop limit buy)
3261        let msg = &data[1];
3262        assert_eq!(msg.algo_id, "706620792746729473");
3263        assert_eq!(msg.state, OKXOrderStatus::Live);
3264        assert_eq!(msg.ord_px, "106000"); // Limit price
3265
3266        let account_id = AccountId::new("OKX-001");
3267        let mut instruments = AHashMap::new();
3268
3269        // Create mock instrument
3270        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3271        let instrument = CryptoPerpetual::new(
3272            instrument_id,
3273            Symbol::from("BTC-USDT-SWAP"),
3274            Currency::BTC(),
3275            Currency::USDT(),
3276            Currency::USDT(),
3277            false, // is_inverse
3278            2,     // price_precision
3279            8,     // size_precision
3280            Price::from("0.01"),
3281            Quantity::from("0.00000001"),
3282            None,
3283            None,
3284            None,
3285            None,
3286            None,
3287            None,
3288            None,
3289            None,
3290            None,
3291            None,
3292            None,
3293            None,
3294            0.into(), // ts_event
3295            0.into(), // ts_init
3296        );
3297        instruments.insert(
3298            Ustr::from("BTC-USDT-SWAP"),
3299            InstrumentAny::CryptoPerpetual(instrument),
3300        );
3301
3302        let result =
3303            parse_algo_order_msg(msg.clone(), account_id, &instruments, UnixNanos::default());
3304
3305        assert!(result.is_ok());
3306        let report = result.unwrap();
3307
3308        if let ExecutionReport::Order(status_report) = report {
3309            assert_eq!(status_report.order_type, OrderType::StopLimit);
3310            assert_eq!(status_report.order_side, OrderSide::Buy);
3311            assert_eq!(status_report.quantity, Quantity::from("0.02000000"));
3312            assert_eq!(status_report.trigger_price, Some(Price::from("105000.00")));
3313            assert_eq!(status_report.trigger_type, Some(TriggerType::MarkPrice));
3314            assert_eq!(status_report.price, Some(Price::from("106000.00"))); // Has limit price
3315        } else {
3316            panic!("Expected Order report");
3317        }
3318    }
3319
3320    #[rstest]
3321    fn test_parse_trigger_order_from_regular_channel() {
3322        let json_data = load_test_json("ws_orders_trigger.json");
3323        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3324        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3325
3326        // Test triggered order that came through regular orders channel
3327        let msg = &data[0];
3328        assert_eq!(msg.ord_type, OKXOrderType::Trigger);
3329        assert_eq!(msg.state, OKXOrderStatus::Filled);
3330
3331        let account_id = AccountId::new("OKX-001");
3332        let mut instruments = AHashMap::new();
3333
3334        // Create mock instrument
3335        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3336        let instrument = CryptoPerpetual::new(
3337            instrument_id,
3338            Symbol::from("BTC-USDT-SWAP"),
3339            Currency::BTC(),
3340            Currency::USDT(),
3341            Currency::USDT(),
3342            false, // is_inverse
3343            2,     // price_precision
3344            8,     // size_precision
3345            Price::from("0.01"),
3346            Quantity::from("0.00000001"),
3347            None,
3348            None,
3349            None,
3350            None,
3351            None,
3352            None,
3353            None,
3354            None,
3355            None,
3356            None,
3357            None,
3358            None,
3359            0.into(), // ts_event
3360            0.into(), // ts_init
3361        );
3362        instruments.insert(
3363            Ustr::from("BTC-USDT-SWAP"),
3364            InstrumentAny::CryptoPerpetual(instrument),
3365        );
3366        let fee_cache = AHashMap::new();
3367        let filled_qty_cache = AHashMap::new();
3368
3369        let result = parse_order_msg_vec(
3370            vec![msg.clone()],
3371            account_id,
3372            &instruments,
3373            &fee_cache,
3374            &filled_qty_cache,
3375            UnixNanos::default(),
3376        );
3377
3378        assert!(result.is_ok());
3379        let reports = result.unwrap();
3380        assert_eq!(reports.len(), 1);
3381
3382        if let ExecutionReport::Fill(fill_report) = &reports[0] {
3383            assert_eq!(fill_report.order_side, OrderSide::Sell);
3384            assert_eq!(fill_report.last_qty, Quantity::from("0.01000000"));
3385            assert_eq!(fill_report.last_px, Price::from("101950.00"));
3386        } else {
3387            panic!("Expected Fill report for filled trigger order");
3388        }
3389    }
3390
3391    #[rstest]
3392    fn test_parse_liquidation_order() {
3393        let json_data = load_test_json("ws_orders_liquidation.json");
3394        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3395        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3396
3397        // Test liquidation order
3398        let msg = &data[0];
3399        assert_eq!(msg.category, OKXOrderCategory::FullLiquidation);
3400        assert_eq!(msg.state, OKXOrderStatus::Filled);
3401        assert_eq!(msg.inst_id.as_str(), "BTC-USDT-SWAP");
3402
3403        let account_id = AccountId::new("OKX-001");
3404        let mut instruments = AHashMap::new();
3405
3406        // Create mock instrument
3407        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3408        let instrument = CryptoPerpetual::new(
3409            instrument_id,
3410            Symbol::from("BTC-USDT-SWAP"),
3411            Currency::BTC(),
3412            Currency::USDT(),
3413            Currency::USDT(),
3414            false, // is_inverse
3415            2,     // price_precision
3416            8,     // size_precision
3417            Price::from("0.01"),
3418            Quantity::from("0.00000001"),
3419            None,
3420            None,
3421            None,
3422            None,
3423            None,
3424            None,
3425            None,
3426            None,
3427            None,
3428            None,
3429            None,
3430            None,
3431            0.into(), // ts_event
3432            0.into(), // ts_init
3433        );
3434        instruments.insert(
3435            Ustr::from("BTC-USDT-SWAP"),
3436            InstrumentAny::CryptoPerpetual(instrument),
3437        );
3438        let fee_cache = AHashMap::new();
3439        let filled_qty_cache = AHashMap::new();
3440
3441        let result = parse_order_msg_vec(
3442            vec![msg.clone()],
3443            account_id,
3444            &instruments,
3445            &fee_cache,
3446            &filled_qty_cache,
3447            UnixNanos::default(),
3448        );
3449
3450        assert!(result.is_ok());
3451        let reports = result.unwrap();
3452        assert_eq!(reports.len(), 1);
3453
3454        // Verify it's a fill report for a liquidation
3455        if let ExecutionReport::Fill(fill_report) = &reports[0] {
3456            assert_eq!(fill_report.order_side, OrderSide::Sell);
3457            assert_eq!(fill_report.last_qty, Quantity::from("0.50000000"));
3458            assert_eq!(fill_report.last_px, Price::from("40000.00"));
3459            assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3460        } else {
3461            panic!("Expected Fill report for liquidation order");
3462        }
3463    }
3464
3465    #[rstest]
3466    fn test_parse_adl_order() {
3467        let json_data = load_test_json("ws_orders_adl.json");
3468        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3469        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3470
3471        // Test ADL order
3472        let msg = &data[0];
3473        assert_eq!(msg.category, OKXOrderCategory::Adl);
3474        assert_eq!(msg.state, OKXOrderStatus::Filled);
3475        assert_eq!(msg.inst_id.as_str(), "ETH-USDT-SWAP");
3476
3477        let account_id = AccountId::new("OKX-001");
3478        let mut instruments = AHashMap::new();
3479
3480        // Create mock instrument
3481        let instrument_id = InstrumentId::from("ETH-USDT-SWAP.OKX");
3482        let instrument = CryptoPerpetual::new(
3483            instrument_id,
3484            Symbol::from("ETH-USDT-SWAP"),
3485            Currency::ETH(),
3486            Currency::USDT(),
3487            Currency::USDT(),
3488            false, // is_inverse
3489            2,     // price_precision
3490            8,     // size_precision
3491            Price::from("0.01"),
3492            Quantity::from("0.00000001"),
3493            None,
3494            None,
3495            None,
3496            None,
3497            None,
3498            None,
3499            None,
3500            None,
3501            None,
3502            None,
3503            None,
3504            None,
3505            0.into(), // ts_event
3506            0.into(), // ts_init
3507        );
3508        instruments.insert(
3509            Ustr::from("ETH-USDT-SWAP"),
3510            InstrumentAny::CryptoPerpetual(instrument),
3511        );
3512        let fee_cache = AHashMap::new();
3513        let filled_qty_cache = AHashMap::new();
3514
3515        let result = parse_order_msg_vec(
3516            vec![msg.clone()],
3517            account_id,
3518            &instruments,
3519            &fee_cache,
3520            &filled_qty_cache,
3521            UnixNanos::default(),
3522        );
3523
3524        assert!(result.is_ok());
3525        let reports = result.unwrap();
3526        assert_eq!(reports.len(), 1);
3527
3528        // Verify it's a fill report for ADL
3529        if let ExecutionReport::Fill(fill_report) = &reports[0] {
3530            assert_eq!(fill_report.order_side, OrderSide::Buy);
3531            assert_eq!(fill_report.last_qty, Quantity::from("0.30000000"));
3532            assert_eq!(fill_report.last_px, Price::from("41000.00"));
3533            assert_eq!(fill_report.liquidity_side, LiquiditySide::Taker);
3534        } else {
3535            panic!("Expected Fill report for ADL order");
3536        }
3537    }
3538
3539    #[rstest]
3540    fn test_parse_unknown_category_graceful_fallback() {
3541        // Test that unknown/future category values deserialize as Other instead of failing
3542        let json_with_unknown_category = r#"{
3543            "category": "some_future_category_we_dont_know"
3544        }"#;
3545
3546        let result: Result<serde_json::Value, _> = serde_json::from_str(json_with_unknown_category);
3547        assert!(result.is_ok());
3548
3549        // Test deserialization of the category field directly
3550        let category_result: Result<OKXOrderCategory, _> =
3551            serde_json::from_str(r#""some_future_category""#);
3552        assert!(category_result.is_ok());
3553        assert_eq!(category_result.unwrap(), OKXOrderCategory::Other);
3554
3555        // Verify known categories still work
3556        let normal: OKXOrderCategory = serde_json::from_str(r#""normal""#).unwrap();
3557        assert_eq!(normal, OKXOrderCategory::Normal);
3558
3559        let twap: OKXOrderCategory = serde_json::from_str(r#""twap""#).unwrap();
3560        assert_eq!(twap, OKXOrderCategory::Twap);
3561    }
3562
3563    #[rstest]
3564    fn test_parse_partial_liquidation_order() {
3565        // Create a test message with partial liquidation category
3566        let account_id = AccountId::new("OKX-001");
3567        let mut instruments = AHashMap::new();
3568
3569        let instrument_id = InstrumentId::from("BTC-USDT-SWAP.OKX");
3570        let instrument = CryptoPerpetual::new(
3571            instrument_id,
3572            Symbol::from("BTC-USDT-SWAP"),
3573            Currency::BTC(),
3574            Currency::USDT(),
3575            Currency::USDT(),
3576            false,
3577            2,
3578            8,
3579            Price::from("0.01"),
3580            Quantity::from("0.00000001"),
3581            None,
3582            None,
3583            None,
3584            None,
3585            None,
3586            None,
3587            None,
3588            None,
3589            None,
3590            None,
3591            None,
3592            None,
3593            0.into(),
3594            0.into(),
3595        );
3596        instruments.insert(
3597            Ustr::from("BTC-USDT-SWAP"),
3598            InstrumentAny::CryptoPerpetual(instrument),
3599        );
3600
3601        let partial_liq_msg = OKXOrderMsg {
3602            acc_fill_sz: Some("0.25".to_string()),
3603            avg_px: "39000.0".to_string(),
3604            c_time: 1746947317401,
3605            cancel_source: None,
3606            cancel_source_reason: None,
3607            category: OKXOrderCategory::PartialLiquidation,
3608            ccy: Ustr::from("USDT"),
3609            cl_ord_id: String::new(),
3610            algo_cl_ord_id: None,
3611            fee: Some("-9.75".to_string()),
3612            fee_ccy: Ustr::from("USDT"),
3613            fill_px: "39000.0".to_string(),
3614            fill_sz: "0.25".to_string(),
3615            fill_time: 1746947317402,
3616            inst_id: Ustr::from("BTC-USDT-SWAP"),
3617            inst_type: OKXInstrumentType::Swap,
3618            lever: "10.0".to_string(),
3619            ord_id: Ustr::from("2497956918703120888"),
3620            ord_type: OKXOrderType::Market,
3621            pnl: "-2500".to_string(),
3622            pos_side: OKXPositionSide::Long,
3623            px: String::new(),
3624            reduce_only: "false".to_string(),
3625            side: OKXSide::Sell,
3626            state: OKXOrderStatus::Filled,
3627            exec_type: OKXExecType::Taker,
3628            sz: "0.25".to_string(),
3629            td_mode: OKXTradeMode::Isolated,
3630            tgt_ccy: None,
3631            trade_id: "1518905888".to_string(),
3632            u_time: 1746947317402,
3633        };
3634
3635        let fee_cache = AHashMap::new();
3636        let filled_qty_cache = AHashMap::new();
3637        let result = parse_order_msg(
3638            &partial_liq_msg,
3639            account_id,
3640            &instruments,
3641            &fee_cache,
3642            &filled_qty_cache,
3643            UnixNanos::default(),
3644        );
3645
3646        assert!(result.is_ok());
3647        let report = result.unwrap();
3648
3649        // Verify it's a fill report for partial liquidation
3650        if let ExecutionReport::Fill(fill_report) = report {
3651            assert_eq!(fill_report.order_side, OrderSide::Sell);
3652            assert_eq!(fill_report.last_qty, Quantity::from("0.25000000"));
3653            assert_eq!(fill_report.last_px, Price::from("39000.00"));
3654        } else {
3655            panic!("Expected Fill report for partial liquidation order");
3656        }
3657    }
3658
3659    #[rstest]
3660    fn test_websocket_instrument_update_preserves_cached_fees() {
3661        use nautilus_model::{identifiers::InstrumentId, instruments::InstrumentAny};
3662
3663        use crate::common::{models::OKXInstrument, parse::parse_instrument_any};
3664
3665        let ts_init = UnixNanos::default();
3666
3667        // Create initial instrument with fees (simulating HTTP load)
3668        // These values are already in Nautilus format (HTTP client negates OKX values)
3669        let initial_fees = (
3670            Some(Decimal::new(8, 4)),  // Nautilus: 0.0008 (commission)
3671            Some(Decimal::new(10, 4)), // Nautilus: 0.0010 (commission)
3672        );
3673
3674        // Deserialize initial instrument from JSON
3675        let initial_inst_json = serde_json::json!({
3676            "instType": "SPOT",
3677            "instId": "BTC-USD",
3678            "baseCcy": "BTC",
3679            "quoteCcy": "USD",
3680            "settleCcy": "",
3681            "ctVal": "",
3682            "ctMult": "",
3683            "ctValCcy": "",
3684            "optType": "",
3685            "stk": "",
3686            "listTime": "1733454000000",
3687            "expTime": "",
3688            "lever": "",
3689            "tickSz": "0.1",
3690            "lotSz": "0.00000001",
3691            "minSz": "0.00001",
3692            "ctType": "linear",
3693            "alias": "",
3694            "state": "live",
3695            "maxLmtSz": "9999999999",
3696            "maxMktSz": "1000000",
3697            "maxTwapSz": "9999999999.0000000000000000",
3698            "maxIcebergSz": "9999999999.0000000000000000",
3699            "maxTriggerSz": "9999999999.0000000000000000",
3700            "maxStopSz": "1000000",
3701            "uly": "",
3702            "instFamily": "",
3703            "ruleType": "normal",
3704            "maxLmtAmt": "20000000",
3705            "maxMktAmt": "1000000"
3706        });
3707
3708        let initial_inst: OKXInstrument = serde_json::from_value(initial_inst_json)
3709            .expect("Failed to deserialize initial instrument");
3710
3711        // Parse initial instrument with fees
3712        let parsed_initial = parse_instrument_any(
3713            &initial_inst,
3714            None,
3715            None,
3716            initial_fees.0,
3717            initial_fees.1,
3718            ts_init,
3719        )
3720        .expect("Failed to parse initial instrument")
3721        .expect("Initial instrument should not be None");
3722
3723        // Verify fees were applied
3724        if let InstrumentAny::CurrencyPair(ref pair) = parsed_initial {
3725            assert_eq!(pair.maker_fee, dec!(0.0008));
3726            assert_eq!(pair.taker_fee, dec!(0.0010));
3727        } else {
3728            panic!("Expected CurrencyPair instrument");
3729        }
3730
3731        // Build instrument cache with the initial instrument
3732        let mut instruments_cache = AHashMap::new();
3733        instruments_cache.insert(Ustr::from("BTC-USD"), parsed_initial);
3734
3735        // Create WebSocket update message (same structure as initial, simulating a WebSocket update)
3736        let ws_update = serde_json::json!({
3737            "instType": "SPOT",
3738            "instId": "BTC-USD",
3739            "baseCcy": "BTC",
3740            "quoteCcy": "USD",
3741            "settleCcy": "",
3742            "ctVal": "",
3743            "ctMult": "",
3744            "ctValCcy": "",
3745            "optType": "",
3746            "stk": "",
3747            "listTime": "1733454000000",
3748            "expTime": "",
3749            "lever": "",
3750            "tickSz": "0.1",
3751            "lotSz": "0.00000001",
3752            "minSz": "0.00001",
3753            "ctType": "linear",
3754            "alias": "",
3755            "state": "live",
3756            "maxLmtSz": "9999999999",
3757            "maxMktSz": "1000000",
3758            "maxTwapSz": "9999999999.0000000000000000",
3759            "maxIcebergSz": "9999999999.0000000000000000",
3760            "maxTriggerSz": "9999999999.0000000000000000",
3761            "maxStopSz": "1000000",
3762            "uly": "",
3763            "instFamily": "",
3764            "ruleType": "normal",
3765            "maxLmtAmt": "20000000",
3766            "maxMktAmt": "1000000"
3767        });
3768
3769        let instrument_id = InstrumentId::from("BTC-USD.OKX");
3770        let mut funding_cache = AHashMap::new();
3771
3772        // Parse WebSocket update with cache
3773        let result = parse_ws_message_data(
3774            &OKXWsChannel::Instruments,
3775            ws_update,
3776            &instrument_id,
3777            2,
3778            8,
3779            ts_init,
3780            &mut funding_cache,
3781            &instruments_cache,
3782        )
3783        .expect("Failed to parse WebSocket instrument update");
3784
3785        // Verify the update preserves the cached fees
3786        if let Some(NautilusWsMessage::Instrument(boxed_inst)) = result {
3787            if let InstrumentAny::CurrencyPair(pair) = *boxed_inst {
3788                assert_eq!(
3789                    pair.maker_fee,
3790                    Decimal::new(8, 4),
3791                    "Maker fee should be preserved from cache"
3792                );
3793                assert_eq!(
3794                    pair.taker_fee,
3795                    Decimal::new(10, 4),
3796                    "Taker fee should be preserved from cache"
3797                );
3798            } else {
3799                panic!("Expected CurrencyPair instrument from WebSocket update");
3800            }
3801        } else {
3802            panic!("Expected Instrument message from WebSocket update");
3803        }
3804    }
3805
3806    #[rstest]
3807    #[case::fok_order(OKXOrderType::Fok, TimeInForce::Fok)]
3808    #[case::ioc_order(OKXOrderType::Ioc, TimeInForce::Ioc)]
3809    #[case::optimal_limit_ioc_order(OKXOrderType::OptimalLimitIoc, TimeInForce::Ioc)]
3810    #[case::market_order(OKXOrderType::Market, TimeInForce::Gtc)]
3811    #[case::limit_order(OKXOrderType::Limit, TimeInForce::Gtc)]
3812    fn test_parse_time_in_force_from_ord_type(
3813        #[case] okx_ord_type: OKXOrderType,
3814        #[case] expected_tif: TimeInForce,
3815    ) {
3816        let time_in_force = match okx_ord_type {
3817            OKXOrderType::Fok => TimeInForce::Fok,
3818            OKXOrderType::Ioc | OKXOrderType::OptimalLimitIoc => TimeInForce::Ioc,
3819            _ => TimeInForce::Gtc,
3820        };
3821
3822        assert_eq!(
3823            time_in_force, expected_tif,
3824            "OKXOrderType::{okx_ord_type:?} should parse to TimeInForce::{expected_tif:?}"
3825        );
3826    }
3827
3828    #[rstest]
3829    fn test_deserialize_fok_order_message() {
3830        let json_data = load_test_json("ws_orders_fok.json");
3831        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3832        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3833
3834        assert!(!data.is_empty());
3835        assert_eq!(data[0].ord_type, OKXOrderType::Fok);
3836        assert_eq!(data[0].cl_ord_id, "FOK-TEST-001");
3837        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT"));
3838    }
3839
3840    #[rstest]
3841    fn test_deserialize_ioc_order_message() {
3842        let json_data = load_test_json("ws_orders_ioc.json");
3843        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3844        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3845
3846        assert!(!data.is_empty());
3847        assert_eq!(data[0].ord_type, OKXOrderType::Ioc);
3848        assert_eq!(data[0].cl_ord_id, "IOC-TEST-001");
3849        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT"));
3850    }
3851
3852    #[rstest]
3853    fn test_deserialize_optimal_limit_ioc_order_message() {
3854        let json_data = load_test_json("ws_orders_optimal_limit_ioc.json");
3855        let ws_msg: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3856        let data: Vec<OKXOrderMsg> = serde_json::from_value(ws_msg["data"].clone()).unwrap();
3857
3858        assert!(!data.is_empty());
3859        assert_eq!(data[0].ord_type, OKXOrderType::OptimalLimitIoc);
3860        assert_eq!(data[0].cl_ord_id, "OPTIMAL-IOC-TEST-001");
3861        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
3862    }
3863
3864    #[rstest]
3865    fn test_deserialize_regular_order_message() {
3866        let json_data = load_test_json("ws_orders.json");
3867        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3868        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3869
3870        assert!(!data.is_empty());
3871        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
3872        assert_eq!(data[0].state, OKXOrderStatus::Filled);
3873        assert_eq!(data[0].category, OKXOrderCategory::Normal);
3874    }
3875
3876    #[rstest]
3877    fn test_deserialize_algo_order_message() {
3878        let json_data = load_test_json("ws_orders_algo.json");
3879        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3880        let data: Vec<OKXAlgoOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3881
3882        assert!(!data.is_empty());
3883        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
3884    }
3885
3886    #[rstest]
3887    fn test_deserialize_liquidation_order_message() {
3888        let json_data = load_test_json("ws_orders_liquidation.json");
3889        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3890        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3891
3892        assert!(!data.is_empty());
3893        assert_eq!(data[0].category, OKXOrderCategory::FullLiquidation);
3894    }
3895
3896    #[rstest]
3897    fn test_deserialize_adl_order_message() {
3898        let json_data = load_test_json("ws_orders_adl.json");
3899        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3900        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3901
3902        assert!(!data.is_empty());
3903        assert_eq!(data[0].category, OKXOrderCategory::Adl);
3904    }
3905
3906    #[rstest]
3907    fn test_deserialize_trigger_order_message() {
3908        let json_data = load_test_json("ws_orders_trigger.json");
3909        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3910        let data: Vec<OKXOrderMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3911
3912        assert!(!data.is_empty());
3913        assert_eq!(data[0].ord_type, OKXOrderType::Trigger);
3914        assert_eq!(data[0].category, OKXOrderCategory::Normal);
3915    }
3916
3917    #[rstest]
3918    fn test_deserialize_book_snapshot_message() {
3919        let json_data = load_test_json("ws_books_snapshot.json");
3920        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3921        let action: Option<OKXBookAction> =
3922            serde_json::from_value(payload["action"].clone()).unwrap();
3923        let data: Vec<OKXBookMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3924
3925        assert!(!data.is_empty());
3926        assert_eq!(action, Some(OKXBookAction::Snapshot));
3927        assert!(!data[0].asks.is_empty());
3928        assert!(!data[0].bids.is_empty());
3929    }
3930
3931    #[rstest]
3932    fn test_deserialize_book_update_message() {
3933        let json_data = load_test_json("ws_books_update.json");
3934        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3935        let action: Option<OKXBookAction> =
3936            serde_json::from_value(payload["action"].clone()).unwrap();
3937        let data: Vec<OKXBookMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3938
3939        assert!(!data.is_empty());
3940        assert_eq!(action, Some(OKXBookAction::Update));
3941        assert!(!data[0].asks.is_empty());
3942        assert!(!data[0].bids.is_empty());
3943    }
3944
3945    #[rstest]
3946    fn test_deserialize_ticker_message() {
3947        let json_data = load_test_json("ws_tickers.json");
3948        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3949        let data: Vec<OKXTickerMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3950
3951        assert!(!data.is_empty());
3952        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT"));
3953        assert_eq!(data[0].last_px, "9999.99");
3954    }
3955
3956    #[rstest]
3957    fn test_deserialize_candle_message() {
3958        let json_data = load_test_json("ws_candle.json");
3959        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3960        let data: Vec<OKXCandleMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3961
3962        assert!(!data.is_empty());
3963        assert!(!data[0].o.is_empty());
3964        assert!(!data[0].h.is_empty());
3965        assert!(!data[0].l.is_empty());
3966        assert!(!data[0].c.is_empty());
3967    }
3968
3969    #[rstest]
3970    fn test_deserialize_funding_rate_message() {
3971        let json_data = load_test_json("ws_funding_rate.json");
3972        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3973        let data: Vec<OKXFundingRateMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3974
3975        assert!(!data.is_empty());
3976        assert_eq!(data[0].inst_id, Ustr::from("BTC-USDT-SWAP"));
3977    }
3978
3979    #[rstest]
3980    fn test_deserialize_bbo_tbt_message() {
3981        let json_data = load_test_json("ws_bbo_tbt.json");
3982        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3983        let data: Vec<OKXBookMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3984
3985        assert!(!data.is_empty());
3986        assert!(!data[0].asks.is_empty());
3987        assert!(!data[0].bids.is_empty());
3988    }
3989
3990    #[rstest]
3991    fn test_deserialize_trade_message() {
3992        let json_data = load_test_json("ws_trades.json");
3993        let payload: serde_json::Value = serde_json::from_str(&json_data).unwrap();
3994        let data: Vec<OKXTradeMsg> = serde_json::from_value(payload["data"].clone()).unwrap();
3995
3996        assert!(!data.is_empty());
3997        assert_eq!(data[0].inst_id, Ustr::from("BTC-USD"));
3998    }
3999
4000    // ========================================================================
4001    // Tests for parse_order_event and related functions
4002    // ========================================================================
4003
4004    fn create_order_msg_for_event_test(
4005        state: OKXOrderStatus,
4006        cl_ord_id: &str,
4007        ord_id: &str,
4008        px: &str,
4009        sz: &str,
4010    ) -> OKXOrderMsg {
4011        OKXOrderMsg {
4012            acc_fill_sz: Some("0".to_string()),
4013            avg_px: "50000.0".to_string(),
4014            c_time: 1746947317401,
4015            cancel_source: None,
4016            cancel_source_reason: None,
4017            category: OKXOrderCategory::Normal,
4018            ccy: Ustr::from("USDT"),
4019            cl_ord_id: cl_ord_id.to_string(),
4020            algo_cl_ord_id: None,
4021            fee: Some("0".to_string()),
4022            fee_ccy: Ustr::from("USDT"),
4023            fill_px: String::new(),
4024            fill_sz: String::new(),
4025            fill_time: 0,
4026            inst_id: Ustr::from("BTC-USDT-SWAP"),
4027            inst_type: OKXInstrumentType::Swap,
4028            lever: "2.0".to_string(),
4029            ord_id: Ustr::from(ord_id),
4030            ord_type: OKXOrderType::Limit,
4031            pnl: "0".to_string(),
4032            pos_side: OKXPositionSide::Long,
4033            px: px.to_string(),
4034            reduce_only: "false".to_string(),
4035            side: OKXSide::Buy,
4036            state,
4037            exec_type: OKXExecType::Taker,
4038            sz: sz.to_string(),
4039            td_mode: OKXTradeMode::Isolated,
4040            tgt_ccy: None,
4041            trade_id: String::new(),
4042            u_time: 1746947317402,
4043        }
4044    }
4045
4046    #[rstest]
4047    fn test_parse_order_event_live_returns_accepted() {
4048        let instrument = create_stub_instrument();
4049        let msg = create_order_msg_for_event_test(
4050            OKXOrderStatus::Live,
4051            "test_client_123",
4052            "venue_456",
4053            "50000.0",
4054            "0.01",
4055        );
4056
4057        let client_order_id = ClientOrderId::new("test_client_123");
4058        let account_id = AccountId::new("OKX-001");
4059        let trader_id = TraderId::new("TRADER-001");
4060        let strategy_id = StrategyId::new("STRATEGY-001");
4061        let ts_init = UnixNanos::from(1000000000);
4062
4063        let result = parse_order_event(
4064            &msg,
4065            client_order_id,
4066            account_id,
4067            trader_id,
4068            strategy_id,
4069            &InstrumentAny::CryptoPerpetual(instrument),
4070            None,
4071            None,
4072            None,
4073            ts_init,
4074        );
4075
4076        assert!(result.is_ok());
4077        match result.unwrap() {
4078            ParsedOrderEvent::Accepted(accepted) => {
4079                assert_eq!(accepted.client_order_id, client_order_id);
4080                assert_eq!(accepted.venue_order_id, VenueOrderId::new("venue_456"));
4081                assert_eq!(accepted.trader_id, trader_id);
4082                assert_eq!(accepted.strategy_id, strategy_id);
4083            }
4084            other => panic!("Expected Accepted, got {other:?}"),
4085        }
4086    }
4087
4088    #[rstest]
4089    fn test_parse_order_event_live_with_price_change_returns_updated() {
4090        let instrument = create_stub_instrument();
4091        let msg = create_order_msg_for_event_test(
4092            OKXOrderStatus::Live,
4093            "test_client_123",
4094            "venue_456",
4095            "51000.0",
4096            "0.01",
4097        );
4098
4099        let client_order_id = ClientOrderId::new("test_client_123");
4100        let account_id = AccountId::new("OKX-001");
4101        let trader_id = TraderId::new("TRADER-001");
4102        let strategy_id = StrategyId::new("STRATEGY-001");
4103        let ts_init = UnixNanos::from(1000000000);
4104
4105        let previous_state = OrderStateSnapshot {
4106            venue_order_id: VenueOrderId::new("venue_456"),
4107            quantity: Quantity::from("0.01000000"),
4108            price: Some(Price::from("50000.00")),
4109        };
4110
4111        let result = parse_order_event(
4112            &msg,
4113            client_order_id,
4114            account_id,
4115            trader_id,
4116            strategy_id,
4117            &InstrumentAny::CryptoPerpetual(instrument),
4118            None,
4119            None,
4120            Some(&previous_state),
4121            ts_init,
4122        );
4123
4124        assert!(result.is_ok());
4125        match result.unwrap() {
4126            ParsedOrderEvent::Updated(updated) => {
4127                assert_eq!(updated.client_order_id, client_order_id);
4128                assert_eq!(updated.price, Some(Price::from("51000.00")));
4129            }
4130            other => panic!("Expected Updated, got {other:?}"),
4131        }
4132    }
4133
4134    #[rstest]
4135    fn test_parse_order_event_live_with_quantity_change_returns_updated() {
4136        let instrument = create_stub_instrument();
4137        let msg = create_order_msg_for_event_test(
4138            OKXOrderStatus::Live,
4139            "test_client_123",
4140            "venue_456",
4141            "50000.0",
4142            "0.02",
4143        );
4144
4145        let client_order_id = ClientOrderId::new("test_client_123");
4146        let account_id = AccountId::new("OKX-001");
4147        let trader_id = TraderId::new("TRADER-001");
4148        let strategy_id = StrategyId::new("STRATEGY-001");
4149        let ts_init = UnixNanos::from(1000000000);
4150        let previous_state = OrderStateSnapshot {
4151            venue_order_id: VenueOrderId::new("venue_456"),
4152            quantity: Quantity::from("0.01000000"),
4153            price: Some(Price::from("50000.00")),
4154        };
4155
4156        let result = parse_order_event(
4157            &msg,
4158            client_order_id,
4159            account_id,
4160            trader_id,
4161            strategy_id,
4162            &InstrumentAny::CryptoPerpetual(instrument),
4163            None,
4164            None,
4165            Some(&previous_state),
4166            ts_init,
4167        );
4168
4169        assert!(result.is_ok());
4170        match result.unwrap() {
4171            ParsedOrderEvent::Updated(updated) => {
4172                assert_eq!(updated.client_order_id, client_order_id);
4173                assert_eq!(updated.quantity, Quantity::from("0.02000000"));
4174            }
4175            other => panic!("Expected Updated, got {other:?}"),
4176        }
4177    }
4178
4179    #[rstest]
4180    fn test_parse_order_event_canceled_returns_canceled() {
4181        let instrument = create_stub_instrument();
4182        let msg = create_order_msg_for_event_test(
4183            OKXOrderStatus::Canceled,
4184            "test_client_123",
4185            "venue_456",
4186            "50000.0",
4187            "0.01",
4188        );
4189
4190        let client_order_id = ClientOrderId::new("test_client_123");
4191        let account_id = AccountId::new("OKX-001");
4192        let trader_id = TraderId::new("TRADER-001");
4193        let strategy_id = StrategyId::new("STRATEGY-001");
4194        let ts_init = UnixNanos::from(1000000000);
4195
4196        let result = parse_order_event(
4197            &msg,
4198            client_order_id,
4199            account_id,
4200            trader_id,
4201            strategy_id,
4202            &InstrumentAny::CryptoPerpetual(instrument),
4203            None,
4204            None,
4205            None,
4206            ts_init,
4207        );
4208
4209        assert!(result.is_ok());
4210        match result.unwrap() {
4211            ParsedOrderEvent::Canceled(canceled) => {
4212                assert_eq!(canceled.client_order_id, client_order_id);
4213                assert_eq!(
4214                    canceled.venue_order_id,
4215                    Some(VenueOrderId::new("venue_456"))
4216                );
4217            }
4218            other => panic!("Expected Canceled, got {other:?}"),
4219        }
4220    }
4221
4222    #[rstest]
4223    fn test_parse_order_event_canceled_with_expiry_reason_returns_expired() {
4224        let instrument = create_stub_instrument();
4225        let mut msg = create_order_msg_for_event_test(
4226            OKXOrderStatus::Canceled,
4227            "test_client_123",
4228            "venue_456",
4229            "50000.0",
4230            "0.01",
4231        );
4232        msg.cancel_source_reason = Some("GTD order expired".to_string());
4233
4234        let client_order_id = ClientOrderId::new("test_client_123");
4235        let account_id = AccountId::new("OKX-001");
4236        let trader_id = TraderId::new("TRADER-001");
4237        let strategy_id = StrategyId::new("STRATEGY-001");
4238        let ts_init = UnixNanos::from(1000000000);
4239
4240        let result = parse_order_event(
4241            &msg,
4242            client_order_id,
4243            account_id,
4244            trader_id,
4245            strategy_id,
4246            &InstrumentAny::CryptoPerpetual(instrument),
4247            None,
4248            None,
4249            None,
4250            ts_init,
4251        );
4252
4253        assert!(result.is_ok());
4254        match result.unwrap() {
4255            ParsedOrderEvent::Expired(expired) => {
4256                assert_eq!(expired.client_order_id, client_order_id);
4257                assert_eq!(expired.venue_order_id, Some(VenueOrderId::new("venue_456")));
4258            }
4259            other => panic!("Expected Expired, got {other:?}"),
4260        }
4261    }
4262
4263    #[rstest]
4264    fn test_parse_order_event_effective_returns_triggered() {
4265        let instrument = create_stub_instrument();
4266        let msg = create_order_msg_for_event_test(
4267            OKXOrderStatus::Effective,
4268            "test_client_123",
4269            "venue_456",
4270            "50000.0",
4271            "0.01",
4272        );
4273
4274        let client_order_id = ClientOrderId::new("test_client_123");
4275        let account_id = AccountId::new("OKX-001");
4276        let trader_id = TraderId::new("TRADER-001");
4277        let strategy_id = StrategyId::new("STRATEGY-001");
4278        let ts_init = UnixNanos::from(1000000000);
4279
4280        let result = parse_order_event(
4281            &msg,
4282            client_order_id,
4283            account_id,
4284            trader_id,
4285            strategy_id,
4286            &InstrumentAny::CryptoPerpetual(instrument),
4287            None,
4288            None,
4289            None,
4290            ts_init,
4291        );
4292
4293        assert!(result.is_ok());
4294        match result.unwrap() {
4295            ParsedOrderEvent::Triggered(triggered) => {
4296                assert_eq!(triggered.client_order_id, client_order_id);
4297                assert_eq!(
4298                    triggered.venue_order_id,
4299                    Some(VenueOrderId::new("venue_456"))
4300                );
4301            }
4302            other => panic!("Expected Triggered, got {other:?}"),
4303        }
4304    }
4305
4306    #[rstest]
4307    fn test_parse_order_event_filled_with_fill_data_returns_fill() {
4308        let instrument = create_stub_instrument();
4309        let mut msg = create_order_msg_for_event_test(
4310            OKXOrderStatus::Filled,
4311            "test_client_123",
4312            "venue_456",
4313            "50000.0",
4314            "0.01",
4315        );
4316        msg.fill_sz = "0.01".to_string();
4317        msg.fill_px = "50000.0".to_string();
4318        msg.trade_id = "trade_789".to_string();
4319        msg.acc_fill_sz = Some("0.01".to_string());
4320
4321        let client_order_id = ClientOrderId::new("test_client_123");
4322        let account_id = AccountId::new("OKX-001");
4323        let trader_id = TraderId::new("TRADER-001");
4324        let strategy_id = StrategyId::new("STRATEGY-001");
4325        let ts_init = UnixNanos::from(1000000000);
4326
4327        let result = parse_order_event(
4328            &msg,
4329            client_order_id,
4330            account_id,
4331            trader_id,
4332            strategy_id,
4333            &InstrumentAny::CryptoPerpetual(instrument),
4334            None,
4335            None,
4336            None,
4337            ts_init,
4338        );
4339
4340        assert!(result.is_ok());
4341        match result.unwrap() {
4342            ParsedOrderEvent::Fill(fill) => {
4343                assert_eq!(fill.client_order_id, Some(client_order_id));
4344                assert_eq!(fill.venue_order_id, VenueOrderId::new("venue_456"));
4345                assert_eq!(fill.trade_id, TradeId::from("trade_789"));
4346            }
4347            other => panic!("Expected Fill, got {other:?}"),
4348        }
4349    }
4350
4351    // ========================================================================
4352    // Tests for is_order_expired_by_reason
4353    // ========================================================================
4354
4355    #[rstest]
4356    fn test_is_order_expired_by_reason_gtd_in_reason() {
4357        let mut msg =
4358            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4359        msg.cancel_source_reason = Some("GTD order expired".to_string());
4360        assert!(is_order_expired_by_reason(&msg));
4361    }
4362
4363    #[rstest]
4364    fn test_is_order_expired_by_reason_timeout_in_reason() {
4365        let mut msg =
4366            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4367        msg.cancel_source_reason = Some("Order timeout".to_string());
4368        assert!(is_order_expired_by_reason(&msg));
4369    }
4370
4371    #[rstest]
4372    fn test_is_order_expired_by_reason_expir_in_reason() {
4373        let mut msg =
4374            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4375        msg.cancel_source_reason = Some("Expiration reached".to_string());
4376        assert!(is_order_expired_by_reason(&msg));
4377    }
4378
4379    #[rstest]
4380    fn test_is_order_expired_by_reason_source_code_5() {
4381        let mut msg =
4382            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4383        msg.cancel_source = Some("5".to_string());
4384        assert!(is_order_expired_by_reason(&msg));
4385    }
4386
4387    #[rstest]
4388    fn test_is_order_expired_by_reason_source_time_expired() {
4389        let mut msg =
4390            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4391        msg.cancel_source = Some("time_expired".to_string());
4392        assert!(is_order_expired_by_reason(&msg));
4393    }
4394
4395    #[rstest]
4396    fn test_is_order_expired_by_reason_false_for_user_cancel() {
4397        let mut msg =
4398            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4399        msg.cancel_source_reason = Some("User canceled".to_string());
4400        msg.cancel_source = Some("1".to_string());
4401        assert!(!is_order_expired_by_reason(&msg));
4402    }
4403
4404    #[rstest]
4405    fn test_is_order_expired_by_reason_false_when_no_reason() {
4406        let msg =
4407            create_order_msg_for_event_test(OKXOrderStatus::Canceled, "test", "123", "100", "1");
4408        assert!(!is_order_expired_by_reason(&msg));
4409    }
4410
4411    // ========================================================================
4412    // Tests for is_order_updated
4413    // ========================================================================
4414
4415    // Regression test: PartiallyFilled order with price change should emit Updated, not StatusOnly
4416    #[rstest]
4417    fn test_parse_order_event_partially_filled_with_price_change_returns_updated() {
4418        let instrument = create_stub_instrument();
4419        let msg = create_order_msg_for_event_test(
4420            OKXOrderStatus::PartiallyFilled,
4421            "test_client_123",
4422            "venue_456",
4423            "51000.0",
4424            "0.01",
4425        );
4426
4427        let client_order_id = ClientOrderId::new("test_client_123");
4428        let account_id = AccountId::new("OKX-001");
4429        let trader_id = TraderId::new("TRADER-001");
4430        let strategy_id = StrategyId::new("STRATEGY-001");
4431        let ts_init = UnixNanos::from(1000000000);
4432
4433        let previous_state = OrderStateSnapshot {
4434            venue_order_id: VenueOrderId::new("venue_456"),
4435            quantity: Quantity::from("0.01000000"),
4436            price: Some(Price::from("50000.00")),
4437        };
4438
4439        let result = parse_order_event(
4440            &msg,
4441            client_order_id,
4442            account_id,
4443            trader_id,
4444            strategy_id,
4445            &InstrumentAny::CryptoPerpetual(instrument),
4446            None,
4447            None,
4448            Some(&previous_state),
4449            ts_init,
4450        );
4451
4452        assert!(result.is_ok());
4453        match result.unwrap() {
4454            ParsedOrderEvent::Updated(updated) => {
4455                assert_eq!(updated.client_order_id, client_order_id);
4456                assert_eq!(updated.price, Some(Price::from("51000.00")));
4457            }
4458            other => {
4459                panic!("Expected Updated for PartiallyFilled with price change, got {other:?}")
4460            }
4461        }
4462    }
4463
4464    #[rstest]
4465    fn test_is_order_updated_price_change() {
4466        let instrument = create_stub_instrument();
4467        let msg = create_order_msg_for_event_test(
4468            OKXOrderStatus::Live,
4469            "test",
4470            "venue_123",
4471            "51000.0",
4472            "0.01",
4473        );
4474
4475        let previous = OrderStateSnapshot {
4476            venue_order_id: VenueOrderId::new("venue_123"),
4477            quantity: Quantity::from("0.01000000"),
4478            price: Some(Price::from("50000.00")),
4479        };
4480
4481        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
4482        assert!(result.is_ok());
4483        assert!(result.unwrap());
4484    }
4485
4486    #[rstest]
4487    fn test_is_order_updated_quantity_change() {
4488        let instrument = create_stub_instrument();
4489        let msg = create_order_msg_for_event_test(
4490            OKXOrderStatus::Live,
4491            "test",
4492            "venue_123",
4493            "50000.0",
4494            "0.02", // New quantity
4495        );
4496
4497        let previous = OrderStateSnapshot {
4498            venue_order_id: VenueOrderId::new("venue_123"),
4499            quantity: Quantity::from("0.01000000"), // Old quantity
4500            price: Some(Price::from("50000.00")),
4501        };
4502
4503        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
4504        assert!(result.is_ok());
4505        assert!(result.unwrap());
4506    }
4507
4508    #[rstest]
4509    fn test_is_order_updated_venue_id_change() {
4510        let instrument = create_stub_instrument();
4511        let msg = create_order_msg_for_event_test(
4512            OKXOrderStatus::Live,
4513            "test",
4514            "venue_456", // New venue ID
4515            "50000.0",
4516            "0.01",
4517        );
4518
4519        let previous = OrderStateSnapshot {
4520            venue_order_id: VenueOrderId::new("venue_123"), // Old venue ID
4521            quantity: Quantity::from("0.01000000"),
4522            price: Some(Price::from("50000.00")),
4523        };
4524
4525        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
4526        assert!(result.is_ok());
4527        assert!(result.unwrap());
4528    }
4529
4530    #[rstest]
4531    fn test_is_order_updated_no_change() {
4532        let instrument = create_stub_instrument();
4533        let msg = create_order_msg_for_event_test(
4534            OKXOrderStatus::Live,
4535            "test",
4536            "venue_123",
4537            "50000.0",
4538            "0.01",
4539        );
4540
4541        let previous = OrderStateSnapshot {
4542            venue_order_id: VenueOrderId::new("venue_123"),
4543            quantity: Quantity::from("0.01000000"),
4544            price: Some(Price::from("50000.00")),
4545        };
4546
4547        let result = is_order_updated(&msg, &previous, &InstrumentAny::CryptoPerpetual(instrument));
4548        assert!(result.is_ok());
4549        assert!(!result.unwrap());
4550    }
4551}