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