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