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