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