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