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