1use std::sync::{
19 Arc,
20 atomic::{AtomicBool, AtomicU64, Ordering},
21};
22
23use ahash::{AHashMap, AHashSet};
24use anyhow::Context;
25use futures_util::{StreamExt, pin_mut};
26use nautilus_common::{
27 cache::quote::QuoteCache,
28 clients::DataClient,
29 live::{
30 dst::time::{self, Duration, Instant},
31 runner::get_data_event_sender,
32 sender::EventSender,
33 },
34 messages::{
35 DataEvent,
36 data::{
37 BarsResponse, BookResponse, DataResponse, FundingRatesResponse, InstrumentResponse,
38 InstrumentsResponse, OptionChainReferencePriceResponse, RequestBars,
39 RequestBookSnapshot, RequestFundingRates, RequestInstrument, RequestInstruments,
40 RequestOptionChainReferencePrice, RequestTrades, SubscribeBars, SubscribeBookDeltas,
41 SubscribeBookDepth, SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
42 SubscribeInstrumentStatus, SubscribeInstruments, SubscribeMarkPrices,
43 SubscribeOptionGreeks, SubscribeQuotes, SubscribeTrades, TradesResponse,
44 UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeBookDepth, UnsubscribeFundingRates,
45 UnsubscribeIndexPrices, UnsubscribeInstrument, UnsubscribeInstrumentStatus,
46 UnsubscribeMarkPrices, UnsubscribeOptionGreeks, UnsubscribeQuotes, UnsubscribeTrades,
47 },
48 },
49};
50use nautilus_core::{
51 AtomicMap, AtomicSet, Params, UnixNanos,
52 datetime::datetime_to_unix_nanos,
53 time::{AtomicTime, get_atomic_clock_realtime},
54};
55use nautilus_live::{
56 SocketControl,
57 book::snapshot::snapshot_expired,
58 task::{TaskGroup, TaskGroupGuard, TaskSpawner},
59};
60use nautilus_model::{
61 data::{Data, FundingRateUpdate, InstrumentStatus},
62 enums::{BookType, GreeksConvention, MarketStatusAction},
63 identifiers::{ClientId, InstrumentId, Venue},
64 instruments::{Instrument, InstrumentAny},
65};
66use ustr::Ustr;
67
68use crate::{
69 book::{
70 BookChannelScope, BookSequenceOutcome,
71 recovery::{
72 is_retryable_code, spawn_recovery_monitor, spawn_recovery_task, start_recovery,
73 },
74 sync::{BookSyncTracker, log_sync_signals},
75 },
76 common::{
77 consts::{
78 OKX_VENUE, OKX_WS_HEARTBEAT_SECS, resolve_book_depth, resolve_instrument_families,
79 select_book_channel, should_retry_error_code,
80 },
81 enums::{
82 OKXBookAction, OKXBookChannel, OKXContractType, OKXGreeksType, OKXInstrumentStatus,
83 OKXInstrumentType, OKXVipLevel,
84 },
85 models::OKXInstrument,
86 parse::{
87 extract_inst_family, is_okx_spread_symbol, okx_instrument_type_from_symbol,
88 okx_status_to_market_action, parse_base_quote_from_symbol, parse_instrument_any,
89 parse_instrument_id, parse_millisecond_timestamp, parse_price, parse_quantity,
90 },
91 task::{spawn_task, terminate_tasks},
92 },
93 config::OKXDataClientConfig,
94 http::{
95 client::{OKXHttpClient, OKXInstrumentDefinitionError},
96 query::GetSpreadsParams,
97 },
98 websocket::{
99 client::OKXWebSocketClient,
100 enums::OKXWsChannel,
101 error::OKXWsError,
102 handler::SnapshotGate,
103 messages::{NautilusWsMessage, OKXBookMsg, OKXOptionSummaryMsg, OKXWsMessage},
104 parse::{
105 extract_fees_from_cached_instrument, parse_book_depth_msg, parse_book_msg_vec,
106 parse_index_price_msg_vec, parse_option_summary_greeks, parse_rpi_book_msg_vec,
107 parse_ws_message_data,
108 },
109 },
110};
111
112const BOOK_SNAPSHOT_DEPTH: usize = 5;
113
114#[derive(Debug)]
115pub struct OKXDataClient {
116 client_id: ClientId,
117 config: OKXDataClientConfig,
118 http_client: OKXHttpClient,
119 ws_public: Option<OKXWebSocketClient>,
120 ws_business: Option<OKXWebSocketClient>,
121 is_connected: AtomicBool,
122 transports_started: bool,
123 tasks: TaskGroup,
124 data_sender: EventSender<DataEvent>,
125 instruments_by_symbol: Arc<AtomicMap<Ustr, InstrumentAny>>,
128 instrument_update_lock: Arc<InstrumentUpdateLock>,
131 book_channels: Arc<AtomicMap<InstrumentId, OKXBookChannel>>,
132 book_sync: BookSyncTracker,
133 book_deltas: Arc<AtomicSet<InstrumentId>>,
134 book_depths: Arc<AtomicMap<InstrumentId, usize>>,
135 index_ticker_map: Arc<AtomicMap<Ustr, AHashSet<Ustr>>>,
136 option_greeks_subs: Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
137 option_summary_family_subs: Arc<parking_lot::Mutex<AHashMap<Ustr, usize>>>,
141 clock: &'static AtomicTime,
142}
143
144impl OKXDataClient {
145 pub fn new(client_id: ClientId, config: OKXDataClientConfig) -> anyhow::Result<Self> {
151 let clock = get_atomic_clock_realtime();
152 let data_sender = get_data_event_sender();
153 let api_key = config
154 .api_key
155 .as_ref()
156 .map(|value| value.expose_secret().to_owned());
157 let api_secret = config
158 .api_secret
159 .as_ref()
160 .map(|value| value.expose_secret().to_owned());
161 let api_passphrase = config
162 .api_passphrase
163 .as_ref()
164 .map(|value| value.expose_secret().to_owned());
165 let proxy_url = config
166 .proxy_url
167 .as_ref()
168 .map(|value| value.expose_secret().to_owned());
169
170 let http_client = if config.has_api_credentials() {
171 OKXHttpClient::with_credentials(
172 api_key,
173 api_secret,
174 api_passphrase,
175 Some(config.http_base_url()),
176 config.http_timeout_secs,
177 config.max_retries,
178 config.retry_delay_initial_ms,
179 config.retry_delay_max_ms,
180 config.environment,
181 proxy_url.clone(),
182 )?
183 } else {
184 OKXHttpClient::new(
185 Some(config.http_base_url()),
186 config.http_timeout_secs,
187 config.max_retries,
188 config.retry_delay_initial_ms,
189 config.retry_delay_max_ms,
190 config.environment,
191 proxy_url.clone(),
192 )?
193 };
194
195 let ws_public = OKXWebSocketClient::new(
196 Some(config.ws_public_url()),
197 None,
198 None,
199 None,
200 None,
201 Some(OKX_WS_HEARTBEAT_SECS),
202 None,
203 config.transport_backend,
204 proxy_url.clone(),
205 )
206 .context("failed to construct OKX public websocket client")?
207 .with_socket_control(SocketControl::new(
208 client_id,
209 Some(*OKX_VENUE),
210 "okx-public-data-streams",
211 ));
212
213 let ws_business = if config.requires_business_ws() {
214 let ws = OKXWebSocketClient::new(
215 Some(config.ws_business_url()),
216 None, None,
218 None,
219 None,
220 Some(OKX_WS_HEARTBEAT_SECS),
221 None,
222 config.transport_backend,
223 proxy_url,
224 )
225 .context("failed to construct OKX business websocket client")?
226 .with_socket_control(SocketControl::new(
227 client_id,
228 Some(*OKX_VENUE),
229 "okx-business-data-streams",
230 ));
231 Some(ws)
232 } else {
233 None
234 };
235
236 if let Some(vip_level) = config.vip_level {
237 ws_public.set_vip_level(vip_level);
238
239 if let Some(ref ws) = ws_business {
240 ws.set_vip_level(vip_level);
241 }
242 }
243
244 Ok(Self {
245 client_id,
246 config,
247 http_client,
248 ws_public: Some(ws_public),
249 ws_business,
250 is_connected: AtomicBool::new(false),
251 transports_started: false,
252 tasks: TaskGroup::new(),
253 data_sender,
254 instruments_by_symbol: Arc::new(AtomicMap::new()),
255 instrument_update_lock: Arc::new(InstrumentUpdateLock::default()),
256 book_channels: Arc::new(AtomicMap::new()),
257 book_sync: BookSyncTracker::default(),
258 book_deltas: Arc::new(AtomicSet::new()),
259 book_depths: Arc::new(AtomicMap::new()),
260 index_ticker_map: Arc::new(AtomicMap::new()),
261 option_greeks_subs: Arc::new(AtomicMap::new()),
262 option_summary_family_subs: Arc::new(parking_lot::Mutex::new(AHashMap::new())),
263 clock,
264 })
265 }
266
267 fn venue(&self) -> Venue {
268 *OKX_VENUE
269 }
270
271 fn vip_level(&self) -> Option<OKXVipLevel> {
272 self.ws_public.as_ref().map(OKXWebSocketClient::vip_level)
273 }
274
275 fn public_ws(&self) -> anyhow::Result<&OKXWebSocketClient> {
276 self.ws_public
277 .as_ref()
278 .context("public websocket client not initialized")
279 }
280
281 fn business_ws(&self) -> anyhow::Result<&OKXWebSocketClient> {
282 self.ws_business
283 .as_ref()
284 .context("business websocket client not available (credentials required)")
285 }
286
287 fn send_data(sender: &EventSender<DataEvent>, data: Data) {
288 if let Err(e) = sender.send(DataEvent::Data(data)) {
289 log::error!("Failed to emit data event: {e}");
290 }
291 }
292
293 fn send_book_depth(
294 messages: &mut [OKXBookMsg],
295 instrument: &InstrumentAny,
296 subscriptions: &AtomicMap<InstrumentId, usize>,
297 sender: &EventSender<DataEvent>,
298 ts_init: UnixNanos,
299 spread: bool,
300 ) {
301 let Some(limit) = subscriptions.get_cloned(&instrument.id()) else {
302 return;
303 };
304
305 let result = messages
306 .iter_mut()
307 .map(|message| {
308 if spread {
309 for level in message.bids.iter_mut().chain(&mut message.asks) {
310 level.orders_count = level.liquidated_orders_count.clone();
311 }
312 }
313
314 let mut depth = parse_book_depth_msg(
315 message,
316 instrument.id(),
317 instrument.price_precision(),
318 instrument.size_precision(),
319 ts_init,
320 )?;
321 depth.bids.truncate(limit);
322 depth.asks.truncate(limit);
323 depth.bid_counts.truncate(limit);
324 depth.ask_counts.truncate(limit);
325 Ok(Data::BookDepth(Box::new(depth)))
326 })
327 .collect::<anyhow::Result<Vec<_>>>();
328
329 match result {
330 Ok(data) => {
331 for item in data {
332 Self::send_data(sender, item);
333 }
334 }
335 Err(e) => log::error!("Failed to parse book depth: {e}"),
336 }
337 }
338
339 fn book_channel(
340 &self,
341 instrument_id: InstrumentId,
342 depth: Option<std::num::NonZeroUsize>,
343 params: Option<&Params>,
344 ) -> OKXBookChannel {
345 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
346 return OKXBookChannel::SprdBooks5;
349 }
350
351 let raw_depth = depth.map_or(0, std::num::NonZero::get);
352 let depth = resolve_book_depth(raw_depth);
353 if depth != raw_depth {
354 log::debug!("Clamped book depth {raw_depth} to {depth} (OKX supports 50 or 400)");
355 }
356
357 let rpi = params
358 .and_then(|params| params.get_bool("rpi"))
359 .unwrap_or(false);
360 let vip = self.vip_level().unwrap_or(OKXVipLevel::Vip0);
361
362 if rpi {
363 OKXBookChannel::BooksRpi
364 } else {
365 let channel = select_book_channel(depth, vip);
366 if depth == 50 && channel == OKXBookChannel::Book {
367 log::debug!(
368 "VIP level {vip} insufficient for 50-depth channel, falling back to default"
369 );
370 }
371
372 channel
373 }
374 }
375
376 fn unsubscribe_book_channel(&self, instrument_id: InstrumentId) -> anyhow::Result<()> {
377 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
378 let ws = self.business_ws()?.clone();
379 self.book_channels.remove(&instrument_id);
380 self.book_sync.remove(instrument_id);
381 self.spawn_ws(
382 async move {
383 ws.unsubscribe_spread_book(instrument_id)
384 .await
385 .context("spread book unsubscribe")
386 },
387 "spread book unsubscribe",
388 );
389
390 return Ok(());
391 }
392
393 let ws = self.public_ws()?.clone();
394 let channel = self.book_channels.get_cloned(&instrument_id);
395 self.book_channels.remove(&instrument_id);
396 self.book_sync.remove(instrument_id);
397
398 self.spawn_ws(
399 async move {
400 match channel {
401 Some(OKXBookChannel::Books50L2Tbt) => ws
402 .unsubscribe_book50_l2_tbt(instrument_id)
403 .await
404 .context("books50-l2-tbt unsubscribe")?,
405 Some(OKXBookChannel::BookL2Tbt) => ws
406 .unsubscribe_book_l2_tbt(instrument_id)
407 .await
408 .context("books-l2-tbt unsubscribe")?,
409 Some(OKXBookChannel::Book) => ws
410 .unsubscribe_book(instrument_id)
411 .await
412 .context("book unsubscribe")?,
413 Some(OKXBookChannel::BooksRpi) => ws
414 .unsubscribe_book_rpi(instrument_id)
415 .await
416 .context("books-rpi unsubscribe")?,
417 Some(OKXBookChannel::SprdBooks5) => ws
418 .unsubscribe_book(instrument_id)
419 .await
420 .context("book unsubscribe")?,
421 None => {
422 log::warn!(
423 "Book channel not found for {instrument_id}; unsubscribing fallback channel"
424 );
425 ws.unsubscribe_book(instrument_id)
426 .await
427 .context("book fallback unsubscribe")?;
428 }
429 }
430
431 Ok(())
432 },
433 "order book unsubscribe",
434 );
435
436 Ok(())
437 }
438
439 fn subscribe_book_channel(
440 &self,
441 instrument_id: InstrumentId,
442 channel: OKXBookChannel,
443 ) -> anyhow::Result<()> {
444 if let Some(active) = self.book_channels.get_cloned(&instrument_id) {
445 anyhow::ensure!(
446 active == channel,
447 "Conflicting OKX book channel for {instrument_id}: active {active:?}, requested {channel:?}"
448 );
449 return Ok(());
450 }
451
452 let ws = if channel == OKXBookChannel::SprdBooks5 {
453 self.business_ws()?.clone()
454 } else {
455 self.public_ws()?.clone()
456 };
457
458 let spawner = self.tasks.spawner()?;
459 let gate = SnapshotGate::default();
460 gate.lock().close();
461 self.book_channels.insert(instrument_id, channel);
462 let cancel =
463 self.book_sync
464 .record_subscription(instrument_id, Instant::now(), gate.clone());
465 let guard = cancel.clone().drop_guard();
466 let tracker = self.book_sync.clone();
467 let timeout = Duration::from_secs(self.config.book_snapshot_timeout_secs);
468 spawn_task(&spawner.clone(), async move {
469 let _guard = guard;
470 let result = ws
471 .subscribe_book_channel(instrument_id, channel, cancel.clone(), gate)
472 .await;
473
474 if cancel.is_cancelled() {
475 return;
476 }
477
478 match result {
479 Ok(()) => {
480 if !snapshot_expired(&cancel, timeout).await {
481 return;
482 }
483
484 log::warn!(
485 "Initial book snapshot missing for {instrument_id}; requesting a fresh snapshot"
486 );
487 }
488 Err(e) => {
489 log::warn!("Initial book subscription send failed for {instrument_id}: {e}");
490 }
491 }
492
493 if let Some(recovery) = tracker.claim_subscription_recovery(instrument_id, &cancel) {
494 spawn_recovery_task(
495 instrument_id,
496 channel,
497 tracker,
498 recovery,
499 ws,
500 timeout,
501 &spawner,
502 );
503 }
504 });
505
506 Ok(())
507 }
508
509 fn spawn_ws<F>(&self, fut: F, context: &'static str)
510 where
511 F: Future<Output = anyhow::Result<()>> + Send + 'static,
512 {
513 let fut = async move {
514 if let Err(e) = fut.await {
515 log::error!("{context}: {e:?}");
516 }
517 };
518 self.spawn_task(fut);
519 }
520
521 fn spawn_task<F>(&self, fut: F)
522 where
523 F: Future<Output = ()> + Send + 'static,
524 {
525 match self.tasks.spawner() {
526 Ok(spawner) => spawn_task(&spawner, fut),
527 Err(e) => log::debug!("Skipping task after OKX shutdown began: {e}"),
528 }
529 }
530
531 fn begin_generation_shutdown(&self) {
532 self.tasks.begin_shutdown();
533 self.is_connected.store(false, Ordering::Release);
534
535 if let Some(ws) = self.ws_public.as_ref() {
536 ws.begin_shutdown();
537 }
538
539 if let Some(ws) = self.ws_business.as_ref() {
540 ws.begin_shutdown();
541 }
542 }
543
544 fn register_book_health_monitor(&self) -> anyhow::Result<()> {
545 let interval_duration = Duration::from_secs(self.config.book_stale_check_interval_secs);
546 let threshold = Duration::from_secs(self.config.book_stale_threshold_secs);
547
548 if interval_duration.is_zero() || threshold.is_zero() {
549 return Ok(());
550 }
551
552 let book_sync = self.book_sync.clone();
553 let tasks = self
554 .tasks
555 .spawner()
556 .context("OKX data task admission is closed")?;
557 let cancel = tasks.cancellation_token();
558
559 tasks.spawn(async move {
560 let mut interval = time::interval(interval_duration);
561
562 loop {
563 tokio::select! {
564 biased;
565 () = cancel.cancelled() => {
566 log::debug!("Book health monitor task cancelled");
567 break;
568 }
569 _ = interval.tick() => {
570 log_sync_signals(
571 &book_sync.stale_books(threshold, Instant::now())
572 );
573 }
574 }
575 }
576 })?;
577 Ok(())
578 }
579
580 #[expect(clippy::too_many_arguments)]
581 fn handle_ws_message(
582 message: OKXWsMessage,
583 data_sender: &EventSender<DataEvent>,
584 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
585 http_client: &OKXHttpClient,
586 config: &OKXDataClientConfig,
587 instrument_update_lock: &InstrumentUpdateLock,
588 book_channels: &Arc<AtomicMap<InstrumentId, OKXBookChannel>>,
589 book_sync: &BookSyncTracker,
590 book_depths: &AtomicMap<InstrumentId, usize>,
591 book_deltas: &AtomicSet<InstrumentId>,
592 recovery_ws: Option<&OKXWebSocketClient>,
593 business_ws: Option<&OKXWebSocketClient>,
594 quote_cache: &mut QuoteCache,
595 funding_cache: &mut AHashMap<Ustr, (Ustr, u64)>,
596 index_ticker_map: &Arc<AtomicMap<Ustr, AHashSet<Ustr>>>,
597 option_greeks_subs: &Arc<AtomicMap<InstrumentId, AHashSet<OKXGreeksType>>>,
598 book_channel_scope: BookChannelScope,
599 snapshot_timeout: Duration,
600 tasks: &TaskSpawner,
601 clock: &AtomicTime,
602 ) {
603 let recovery_ws = match book_channel_scope {
607 BookChannelScope::Public => recovery_ws,
608 BookChannelScope::Business => business_ws,
609 };
610
611 match message {
612 OKXWsMessage::BookData {
613 arg,
614 action,
615 mut data,
616 } => {
617 let Some(inst_id) = arg.inst_id else {
618 log::warn!("Book data without inst_id");
619 return;
620 };
621 let instruments_guard = instruments_by_symbol.load();
622 let Some(instrument) = instruments_guard.get(&inst_id) else {
623 log::warn!("No cached instrument for book data: {inst_id}");
624 return;
625 };
626 let ts_init = clock.get_time_ns();
627
628 if arg.channel == OKXWsChannel::Books5 {
629 if action == OKXBookAction::Snapshot {
630 Self::send_book_depth(
631 &mut data,
632 instrument,
633 book_depths,
634 data_sender,
635 ts_init,
636 false,
637 );
638 }
639
640 return;
641 }
642
643 let sequences = data
644 .iter()
645 .map(|msg| (msg.prev_seq_id, msg.seq_id))
646 .collect::<Vec<_>>();
647
648 let data: Vec<_> = data
649 .into_iter()
650 .filter(|msg| {
651 action == OKXBookAction::Snapshot
652 || !msg.bids.is_empty()
653 || !msg.asks.is_empty()
654 })
655 .collect();
656
657 match parse_book_msg_vec(
658 data,
659 &instrument.id(),
660 instrument.price_precision(),
661 instrument.size_precision(),
662 action,
663 ts_init,
664 ) {
665 Ok(data_vec) => {
666 let outcome = book_sync.validate_sequence_if_subscribed(
667 book_channels,
668 instrument.id(),
669 action == OKXBookAction::Snapshot,
670 &sequences,
671 snapshot_timeout,
672 Instant::now(),
673 );
674
675 if !handle_book_sequence_outcome(
676 outcome,
677 instrument.id(),
678 book_channels,
679 book_sync,
680 recovery_ws,
681 snapshot_timeout,
682 tasks,
683 ) {
684 return;
685 }
686
687 for data in data_vec {
688 Self::send_data(data_sender, data);
689 }
690 }
691 Err(e) => {
692 log::error!("Failed to parse book data: {e}");
693 start_recovery(
694 instrument.id(),
695 book_channels,
696 book_sync,
697 recovery_ws,
698 snapshot_timeout,
699 tasks,
700 );
701 }
702 }
703 }
704 OKXWsMessage::RpiBookData { arg, action, data } => {
705 let Some(inst_id) = arg.inst_id else {
706 log::warn!("RPI book data without inst_id");
707 return;
708 };
709 let instruments_guard = instruments_by_symbol.load();
710 let Some(instrument) = instruments_guard.get(&inst_id) else {
711 log::warn!("No cached instrument for RPI book data: {inst_id}");
712 return;
713 };
714 let ts_init = clock.get_time_ns();
715 let sequences = data
716 .iter()
717 .map(|msg| (Some(msg.prev_seq_id), msg.seq_id))
718 .collect::<Vec<_>>();
719
720 let data: Vec<_> = data
721 .into_iter()
722 .filter(|msg| {
723 action == OKXBookAction::Snapshot
724 || !msg.bids.is_empty()
725 || !msg.asks.is_empty()
726 })
727 .collect();
728
729 match parse_rpi_book_msg_vec(
730 data,
731 &instrument.id(),
732 instrument.price_precision(),
733 instrument.size_precision(),
734 action,
735 ts_init,
736 ) {
737 Ok(data_vec) => {
738 let outcome = book_sync.validate_sequence_if_subscribed(
739 book_channels,
740 instrument.id(),
741 action == OKXBookAction::Snapshot,
742 &sequences,
743 snapshot_timeout,
744 Instant::now(),
745 );
746
747 if !handle_book_sequence_outcome(
748 outcome,
749 instrument.id(),
750 book_channels,
751 book_sync,
752 recovery_ws,
753 snapshot_timeout,
754 tasks,
755 ) {
756 return;
757 }
758
759 for data in data_vec {
760 Self::send_data(data_sender, data);
761 }
762 }
763 Err(e) => {
764 log::error!("Failed to parse RPI book data: {e}");
765 start_recovery(
766 instrument.id(),
767 book_channels,
768 book_sync,
769 recovery_ws,
770 snapshot_timeout,
771 tasks,
772 );
773 }
774 }
775 }
776 OKXWsMessage::ChannelData {
777 channel,
778 inst_id,
779 data,
780 } => {
781 if matches!(channel, OKXWsChannel::OptionSummary) {
785 let ts_init = clock.get_time_ns();
786
787 match serde_json::from_value::<Vec<OKXOptionSummaryMsg>>(data) {
788 Ok(msgs) => {
789 let subs = option_greeks_subs.load();
790 let instruments_guard = instruments_by_symbol.load();
791
792 for msg in &msgs {
793 let Some(instrument) = instruments_guard.get(&msg.inst_id) else {
794 continue;
795 };
796 let instrument_id = instrument.id();
797 let Some(conventions) = subs.get(&instrument_id) else {
798 continue;
799 };
800
801 for greeks_type in conventions {
802 match parse_option_summary_greeks(
803 msg,
804 &instrument_id,
805 *greeks_type,
806 ts_init,
807 ) {
808 Ok(greeks) => {
809 if let Err(e) =
810 data_sender.send(DataEvent::OptionGreeks(greeks))
811 {
812 log::error!(
813 "Failed to emit option greeks event: {e}"
814 );
815 }
816 }
817 Err(e) => {
818 log::error!(
819 "Failed to parse option summary for {} ({greeks_type:?}): {e}",
820 msg.inst_id
821 );
822 }
823 }
824 }
825 }
826 }
827 Err(e) => {
828 log::error!("Failed to deserialize option summary data: {e}");
829 }
830 }
831 return;
832 }
833
834 let Some(inst_id) = inst_id else {
835 log::debug!("Channel data without inst_id: {channel:?}");
836 return;
837 };
838
839 if matches!(channel, OKXWsChannel::IndexTickers) {
843 let ts_init = clock.get_time_ns();
844 let map_guard = index_ticker_map.load();
845 let Some(subscribed_symbols) = map_guard.get(&inst_id) else {
846 log::debug!("No subscribed instruments for index ticker: {inst_id}");
847 return;
848 };
849
850 let mut symbols: Vec<Ustr> = subscribed_symbols.iter().copied().collect();
851
852 symbols.sort();
854
855 drop(map_guard);
856
857 let instruments_guard = instruments_by_symbol.load();
858
859 for sym in &symbols {
860 let Some(instrument) = instruments_guard.get(sym) else {
861 log::warn!("No cached instrument for index ticker symbol: {sym}");
862 continue;
863 };
864
865 match parse_index_price_msg_vec(
866 data.clone(),
867 &instrument.id(),
868 instrument.price_precision(),
869 ts_init,
870 ) {
871 Ok(data_vec) => {
872 for d in data_vec {
873 Self::send_data(data_sender, d);
874 }
875 }
876 Err(e) => log::error!("Failed to parse index price data: {e}"),
877 }
878 }
879 return;
880 }
881
882 let instruments_guard = instruments_by_symbol.load();
883 let Some(instrument) = instruments_guard.get(&inst_id) else {
884 log::warn!("No cached instrument for {channel:?}: {inst_id}");
885 return;
886 };
887 let instrument_id = instrument.id();
888 let price_precision = instrument.price_precision();
889 let size_precision = instrument.size_precision();
890 let ts_init = clock.get_time_ns();
891
892 if channel == OKXWsChannel::Books5 {
893 match serde_json::from_value::<Vec<OKXBookMsg>>(data) {
894 Ok(mut messages) => Self::send_book_depth(
895 &mut messages,
896 instrument,
897 book_depths,
898 data_sender,
899 ts_init,
900 false,
901 ),
902 Err(e) => log::error!("Failed to deserialize book depth: {e}"),
903 }
904
905 return;
906 }
907
908 if matches!(channel, OKXWsChannel::SprdBooks5) {
909 let mut msgs: Vec<OKXBookMsg> = match serde_json::from_value(data) {
910 Ok(m) => m,
911 Err(e) => {
912 log::error!("Failed to deserialize spread book data: {e}");
913 return;
914 }
915 };
916
917 Self::send_book_depth(
918 &mut msgs,
919 instrument,
920 book_depths,
921 data_sender,
922 ts_init,
923 true,
924 );
925
926 match parse_book_msg_vec(
927 msgs,
928 &instrument_id,
929 price_precision,
930 size_precision,
931 OKXBookAction::Snapshot,
932 ts_init,
933 ) {
934 Ok(data_vec) => {
935 if !book_sync.record_update_if_subscribed(
936 book_channels,
937 instrument_id,
938 true,
939 Instant::now(),
940 ) {
941 return;
942 }
943
944 if !book_deltas.contains(&instrument_id) {
945 return;
946 }
947
948 for data in data_vec {
949 Self::send_data(data_sender, data);
950 }
951 }
952 Err(e) => {
953 log::error!("Failed to parse spread book data: {e}");
954 start_recovery(
955 instrument_id,
956 book_channels,
957 book_sync,
958 recovery_ws,
959 snapshot_timeout,
960 tasks,
961 );
962 }
963 }
964
965 return;
966 }
967
968 if matches!(channel, OKXWsChannel::BboTbt | OKXWsChannel::SprdBboTbt) {
969 let msgs: Vec<OKXBookMsg> = match serde_json::from_value(data) {
970 Ok(m) => m,
971 Err(e) => {
972 log::error!("Failed to deserialize BboTbt data: {e}");
973 return;
974 }
975 };
976
977 for msg in &msgs {
978 let bid = msg.bids.first();
979 let ask = msg.asks.first();
980 let bid_price =
981 bid.and_then(|e| parse_price(&e.price, price_precision).ok());
982 let bid_size =
983 bid.and_then(|e| parse_quantity(&e.size, size_precision).ok());
984 let ask_price =
985 ask.and_then(|e| parse_price(&e.price, price_precision).ok());
986 let ask_size =
987 ask.and_then(|e| parse_quantity(&e.size, size_precision).ok());
988 let ts_event = parse_millisecond_timestamp(msg.ts);
989
990 match quote_cache.process(
991 instrument_id,
992 bid_price,
993 ask_price,
994 bid_size,
995 ask_size,
996 ts_event,
997 ts_init,
998 ) {
999 Ok(quote) => Self::send_data(data_sender, Data::Quote(quote)),
1000 Err(e) => {
1001 log::debug!("Skipping partial BboTbt for {instrument_id}: {e}");
1002 }
1003 }
1004 }
1005
1006 return;
1007 }
1008
1009 match parse_ws_message_data(
1010 &channel,
1011 data,
1012 &instrument_id,
1013 price_precision,
1014 size_precision,
1015 ts_init,
1016 funding_cache,
1017 &instruments_guard,
1018 ) {
1019 Ok(Some(ws_msg)) => {
1020 dispatch_parsed_data(ws_msg, data_sender, instruments_by_symbol);
1021 }
1022 Ok(None) => {}
1023 Err(e) => log::error!("Failed to parse {channel:?} data: {e}"),
1024 }
1025 }
1026 OKXWsMessage::Instruments(okx_instruments) => {
1027 let ts_init = clock.get_time_ns();
1028 let _update_guard = instrument_update_lock.mutex.lock();
1031
1032 for okx_inst in okx_instruments {
1033 let inst_key = okx_inst.inst_id;
1034 let cached = instruments_by_symbol.get_cloned(&inst_key);
1035 let (margin_init, margin_maint, maker_fee, taker_fee) = cached
1036 .as_ref()
1037 .map_or((None, None, None, None), |instrument| {
1038 extract_fees_from_cached_instrument(instrument)
1039 });
1040 let status_action = okx_status_to_market_action(okx_inst.state);
1041 let is_live = matches!(okx_inst.state, OKXInstrumentStatus::Live);
1042 match parse_instrument_any(
1043 &okx_inst,
1044 margin_init,
1045 margin_maint,
1046 maker_fee,
1047 taker_fee,
1048 ts_init,
1049 ) {
1050 Ok(Some(inst_any)) => {
1051 let instrument_id = inst_any.id();
1052 let is_new_or_changed = cached.is_none_or(|cached| {
1053 !instrument_definitions_match(&cached, &inst_any)
1054 });
1055
1056 if is_new_or_changed
1057 && definition_in_scope(config, &okx_inst, &inst_any)
1058 {
1059 publish_instrument_updates(
1060 std::slice::from_ref(&inst_any),
1061 instruments_by_symbol,
1062 http_client,
1063 recovery_ws,
1064 business_ws,
1065 instrument_update_lock,
1066 data_sender,
1067 );
1068 }
1069
1070 emit_instrument_status(
1071 data_sender,
1072 instrument_id,
1073 status_action,
1074 is_live,
1075 ts_init,
1076 );
1077 }
1078 Ok(None) => {
1079 let instrument_id = instruments_by_symbol
1080 .get_cloned(&inst_key)
1081 .map_or_else(|| parse_instrument_id(inst_key), |i| i.id());
1082 emit_instrument_status(
1083 data_sender,
1084 instrument_id,
1085 status_action,
1086 is_live,
1087 ts_init,
1088 );
1089 }
1090 Err(e) => {
1091 log::warn!("Failed to parse instrument {}: {e}", okx_inst.inst_id);
1092 let instrument_id = instruments_by_symbol
1093 .get_cloned(&inst_key)
1094 .map_or_else(|| parse_instrument_id(inst_key), |i| i.id());
1095 emit_instrument_status(
1096 data_sender,
1097 instrument_id,
1098 status_action,
1099 is_live,
1100 ts_init,
1101 );
1102 }
1103 }
1104 }
1105 }
1106 OKXWsMessage::Orders(_)
1107 | OKXWsMessage::SpreadOrders(_)
1108 | OKXWsMessage::AlgoOrders(_)
1109 | OKXWsMessage::OrderResponse { .. }
1110 | OKXWsMessage::Account(_)
1111 | OKXWsMessage::Positions(_)
1112 | OKXWsMessage::LiquidationWarnings(_)
1113 | OKXWsMessage::SendFailed { .. } => {
1114 log::debug!("Ignoring execution message on data client");
1115 }
1116 OKXWsMessage::SubscriptionFailed {
1117 channel,
1118 inst_id,
1119 code,
1120 msg,
1121 } => {
1122 log::error!(
1123 "OKX rejected {channel:?} subscription for {inst_id:?} \
1124 (code={code}, msg={msg}); no data will flow for this subscription"
1125 );
1126
1127 if let Some(inst_id) = inst_id
1128 && channel.is_book()
1129 && let Some(instrument) = instruments_by_symbol.get_cloned(&inst_id)
1130 {
1131 let instrument_id = instrument.id();
1132 if book_channels
1133 .get_cloned(&instrument_id)
1134 .is_none_or(|selected| {
1135 crate::websocket::client::ws_channel_for_book(selected) != channel
1136 })
1137 {
1138 return;
1139 }
1140
1141 let error = OKXWsError::OkxError {
1142 error_code: code.clone(),
1143 message: msg,
1144 };
1145
1146 if !is_retryable_code(&code) {
1147 book_sync.fail_recovery(instrument_id, None);
1148 return;
1149 }
1150
1151 if book_sync.reject_recovery(instrument_id, error) {
1152 return;
1153 }
1154
1155 start_recovery(
1156 instrument.id(),
1157 book_channels,
1158 book_sync,
1159 recovery_ws,
1160 snapshot_timeout,
1161 tasks,
1162 );
1163 }
1164 }
1165 OKXWsMessage::Error(e) => {
1166 if should_retry_error_code(&e.code) {
1167 log::warn!("OKX websocket error: {e:?}");
1168 } else {
1169 log::error!("OKX websocket error: {e:?}");
1170 }
1171 }
1172 OKXWsMessage::Reconnected => {
1173 log::info!("Websocket reconnected");
1174 quote_cache.clear();
1175 funding_cache.clear();
1176
1177 book_sync.reset_sequences(book_channels, book_channel_scope);
1178
1179 if !snapshot_timeout.is_zero() {
1180 let pending_count = book_sync.seed_pending_snapshots(
1181 book_channels,
1182 book_channel_scope,
1183 snapshot_timeout,
1184 Instant::now(),
1185 );
1186
1187 if pending_count > 0 {
1188 spawn_recovery_monitor(
1189 book_sync.clone(),
1190 recovery_ws.cloned(),
1191 Arc::clone(book_channels),
1192 book_channel_scope,
1193 snapshot_timeout,
1194 tasks,
1195 );
1196 }
1197 }
1198 }
1199 OKXWsMessage::Authenticated => {
1200 log::debug!("Websocket authenticated");
1201 }
1202 }
1203 }
1204
1205 async fn connect_session(&mut self) -> anyhow::Result<()> {
1209 if self.transports_started
1211 || !self.tasks.is_empty()
1212 || !self.tasks.is_open()
1213 || self
1214 .ws_public
1215 .as_ref()
1216 .is_some_and(OKXWebSocketClient::has_task)
1217 || self
1218 .ws_business
1219 .as_ref()
1220 .is_some_and(OKXWebSocketClient::has_task)
1221 {
1222 self.teardown_transports().await?;
1223 }
1224
1225 if !self.tasks.is_open() {
1226 self.tasks
1227 .start_generation()
1228 .context("failed to start OKX data task generation")?;
1229 }
1230 self.transports_started = true;
1231
1232 let all_instruments = fetch_configured_instruments(&self.http_client, &self.config).await?;
1233
1234 let changed = changed_definitions(&all_instruments, &self.instruments_by_symbol);
1238
1239 self.instruments_by_symbol.rcu(|m| {
1240 for instrument in &all_instruments {
1241 m.insert(instrument.symbol().inner(), instrument.clone());
1242 }
1243 });
1244
1245 let instruments: Vec<_> = self
1248 .instruments_by_symbol
1249 .load()
1250 .values()
1251 .cloned()
1252 .collect();
1253
1254 if let Some(ref ws) = self.ws_public {
1255 ws.cache_instruments(&instruments);
1256 }
1257
1258 if let Some(ref ws) = self.ws_business {
1259 ws.cache_instruments(&instruments);
1260 }
1261
1262 publish_instrument_updates(
1263 &changed,
1264 &self.instruments_by_symbol,
1265 &self.http_client,
1266 self.ws_public.as_ref(),
1267 self.ws_business.as_ref(),
1268 &self.instrument_update_lock,
1269 &self.data_sender,
1270 );
1271
1272 let instrument_types = configured_instrument_types(&self.config);
1273
1274 if let Some(ref mut ws) = self.ws_public {
1275 ws.connect()
1276 .await
1277 .context("failed to connect OKX public websocket")?;
1278 ws.wait_until_active(10.0)
1279 .await
1280 .context("public websocket did not become active")?;
1281
1282 let stream = ws.stream();
1283 let sender = self.data_sender.clone();
1284 let insts = self.instruments_by_symbol.clone();
1285 let http = self.http_client.clone();
1286 let config = self.config.clone();
1287 let update_lock = self.instrument_update_lock.clone();
1288 let book_channels = self.book_channels.clone();
1289 let book_sync = self.book_sync.clone();
1290 let book_depths = self.book_depths.clone();
1291 let book_deltas = self.book_deltas.clone();
1292 let recovery_ws = ws.clone();
1293 let business_ws = self.ws_business.clone();
1294 let idx_map = self.index_ticker_map.clone();
1295 let greeks_subs = self.option_greeks_subs.clone();
1296 let tasks = self
1297 .tasks
1298 .spawner()
1299 .context("OKX data task admission is closed")?;
1300 let task_spawner = tasks.clone();
1301 let cancel = tasks.cancellation_token();
1302 let snapshot_timeout = Duration::from_secs(self.config.book_snapshot_timeout_secs);
1303 let clock = self.clock;
1304
1305 tasks
1306 .spawn(async move {
1307 let mut quote_cache = QuoteCache::new();
1308 let mut funding_cache: AHashMap<Ustr, (Ustr, u64)> = AHashMap::new();
1309
1310 pin_mut!(stream);
1311
1312 loop {
1313 tokio::select! {
1314 biased;
1315 () = cancel.cancelled() => {
1316 log::debug!("Public websocket stream task cancelled");
1317 break;
1318 }
1319 Some(message) = stream.next() => {
1320 Self::handle_ws_message(
1321 message,
1322 &sender,
1323 &insts,
1324 &http,
1325 &config,
1326 &update_lock,
1327 &book_channels,
1328 &book_sync,
1329 &book_depths,
1330 &book_deltas,
1331 Some(&recovery_ws),
1332 business_ws.as_ref(),
1333 &mut quote_cache,
1334 &mut funding_cache,
1335 &idx_map,
1336 &greeks_subs,
1337 BookChannelScope::Public,
1338 snapshot_timeout,
1339 &task_spawner,
1340 clock,
1341 );
1342 }
1343 }
1344 }
1345 })
1346 .context("failed to register OKX public WebSocket stream task")?;
1347
1348 for inst_type in &instrument_types {
1349 ws.subscribe_instruments(*inst_type)
1350 .await
1351 .with_context(|| {
1352 format!("failed to subscribe to instrument type {inst_type:?}")
1353 })?;
1354 }
1355 }
1356
1357 if let Some(ref mut ws) = self.ws_business {
1358 ws.connect()
1359 .await
1360 .context("failed to connect OKX business websocket")?;
1361 ws.wait_until_active(10.0)
1362 .await
1363 .context("business websocket did not become active")?;
1364
1365 let stream = ws.stream();
1366 let sender = self.data_sender.clone();
1367 let insts = self.instruments_by_symbol.clone();
1368 let http = self.http_client.clone();
1369 let config = self.config.clone();
1370 let update_lock = self.instrument_update_lock.clone();
1371 let book_channels = self.book_channels.clone();
1372 let book_sync = self.book_sync.clone();
1373 let book_depths = self.book_depths.clone();
1374 let book_deltas = self.book_deltas.clone();
1375 let business_ws = ws.clone();
1376 let idx_map = self.index_ticker_map.clone();
1377 let greeks_subs = self.option_greeks_subs.clone();
1378 let tasks = self
1379 .tasks
1380 .spawner()
1381 .context("OKX data task admission is closed")?;
1382 let task_spawner = tasks.clone();
1383 let cancel = tasks.cancellation_token();
1384 let snapshot_timeout = Duration::from_secs(self.config.book_snapshot_timeout_secs);
1385 let clock = self.clock;
1386
1387 tasks
1388 .spawn(async move {
1389 let mut quote_cache = QuoteCache::new();
1390 let mut funding_cache: AHashMap<Ustr, (Ustr, u64)> = AHashMap::new();
1391
1392 pin_mut!(stream);
1393
1394 loop {
1395 tokio::select! {
1396 biased;
1397 () = cancel.cancelled() => {
1398 log::debug!("Business websocket stream task cancelled");
1399 break;
1400 }
1401 Some(message) = stream.next() => {
1402 Self::handle_ws_message(
1403 message,
1404 &sender,
1405 &insts,
1406 &http,
1407 &config,
1408 &update_lock,
1409 &book_channels,
1410 &book_sync,
1411 &book_depths,
1412 &book_deltas,
1413 None,
1414 Some(&business_ws),
1415 &mut quote_cache,
1416 &mut funding_cache,
1417 &idx_map,
1418 &greeks_subs,
1419 BookChannelScope::Business,
1420 snapshot_timeout,
1421 &task_spawner,
1422 clock,
1423 );
1424 }
1425 }
1426 }
1427 })
1428 .context("failed to register OKX business WebSocket stream task")?;
1429 }
1430
1431 self.register_book_health_monitor()?;
1432 self.register_instrument_refresh()?;
1433 Ok(())
1434 }
1435
1436 fn register_instrument_refresh(&self) -> anyhow::Result<()> {
1441 let minutes = self.config.update_instruments_interval_mins;
1442
1443 if minutes == 0 {
1444 log::debug!("Instrument refresh disabled (update_instruments_interval_mins=0)");
1445 return Ok(());
1446 }
1447
1448 let interval = Duration::from_secs(minutes.saturating_mul(60));
1449 let tasks = self
1450 .tasks
1451 .spawner()
1452 .context("OKX data task admission is closed")?;
1453 let cancel = tasks.cancellation_token();
1454 let http_client = self.http_client.clone();
1455 let config = self.config.clone();
1456 let instruments = self.instruments_by_symbol.clone();
1457 let update_lock = self.instrument_update_lock.clone();
1458 let ws_public = self.ws_public.clone();
1459 let ws_business = self.ws_business.clone();
1460 let data_sender = self.data_sender.clone();
1461 let client_id = self.client_id;
1462
1463 tasks.spawn(async move {
1464 loop {
1465 let sleep = time::sleep(interval);
1466 tokio::pin!(sleep);
1467
1468 tokio::select! {
1469 biased;
1470 () = cancel.cancelled() => break,
1471 () = &mut sleep => {}
1472 }
1473
1474 let result = tokio::select! {
1475 biased;
1476 () = cancel.cancelled() => break,
1477 result = reconcile_instruments(
1478 &http_client,
1479 &config,
1480 &instruments,
1481 &update_lock,
1482 ws_public.as_ref(),
1483 ws_business.as_ref(),
1484 &data_sender,
1485 ) => result,
1486 };
1487
1488 match result {
1489 Ok(summary) => {
1490 log::debug!(
1491 "OKX instruments refreshed: client_id={client_id}, fetched={}, changed={}, missing={}",
1492 summary.fetched,
1493 summary.changed,
1494 summary.missing,
1495 );
1496 }
1497 Err(e) => {
1498 log::warn!(
1499 "Failed to refresh OKX instruments: client_id={client_id}, error={e:?}"
1500 );
1501 }
1502 }
1503 }
1504
1505 log::debug!("Instrument refresh task cancelled");
1506 })?;
1507 Ok(())
1508 }
1509
1510 async fn teardown_transports(&mut self) -> anyhow::Result<()> {
1515 self.transports_started = false;
1516 self.begin_generation_shutdown();
1517
1518 if let Some(ws) = self.ws_public.as_ref() {
1519 ws.request_close().await;
1520 }
1521
1522 if let Some(ws) = self.ws_business.as_ref() {
1523 ws.request_close().await;
1524 }
1525
1526 let task_result = terminate_tasks(&self.tasks, "OKX data client").await;
1527
1528 let public_result = if let Some(ref mut ws) = self.ws_public {
1529 ws.close().await.context("failed to close public websocket")
1530 } else {
1531 Ok(())
1532 };
1533
1534 let business_result = if let Some(ref mut ws) = self.ws_business {
1535 ws.close()
1536 .await
1537 .context("failed to close business websocket")
1538 } else {
1539 Ok(())
1540 };
1541
1542 self.book_deltas.store(AHashSet::new());
1543 self.book_depths.store(AHashMap::new());
1544 self.book_channels.store(AHashMap::new());
1545 self.book_sync.clear();
1546 self.option_greeks_subs
1547 .store(AHashMap::<InstrumentId, AHashSet<OKXGreeksType>>::new());
1548 self.option_summary_family_subs.lock().clear();
1549 self.is_connected.store(false, Ordering::Release);
1550
1551 let mut errors = Vec::new();
1552 if let Err(e) = task_result {
1553 errors.push(e.to_string());
1554 }
1555
1556 if let Err(e) = public_result {
1557 errors.push(e.to_string());
1558 }
1559
1560 if let Err(e) = business_result {
1561 errors.push(e.to_string());
1562 }
1563
1564 if errors.is_empty() {
1565 Ok(())
1566 } else {
1567 anyhow::bail!(errors.join("; "))
1568 }
1569 }
1570}
1571
1572fn handle_book_sequence_outcome(
1573 outcome: BookSequenceOutcome,
1574 instrument_id: InstrumentId,
1575 book_channels: &Arc<AtomicMap<InstrumentId, OKXBookChannel>>,
1576 book_sync: &BookSyncTracker,
1577 recovery_ws: Option<&OKXWebSocketClient>,
1578 snapshot_timeout: Duration,
1579 tasks: &TaskSpawner,
1580) -> bool {
1581 match outcome {
1582 BookSequenceOutcome::Accept => true,
1583 BookSequenceOutcome::Suppress => false,
1584 BookSequenceOutcome::Recover => {
1585 start_recovery(
1586 instrument_id,
1587 book_channels,
1588 book_sync,
1589 recovery_ws,
1590 snapshot_timeout,
1591 tasks,
1592 );
1593 false
1594 }
1595 }
1596}
1597
1598#[derive(Debug, Default)]
1602struct InstrumentUpdateLock {
1603 mutex: parking_lot::Mutex<()>,
1604 write_seq: AtomicU64,
1605}
1606
1607fn dispatch_parsed_data(
1608 msg: NautilusWsMessage,
1609 data_sender: &EventSender<DataEvent>,
1610 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1611) {
1612 match msg {
1613 NautilusWsMessage::Data(payloads) => {
1614 for data in payloads {
1615 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
1616 log::error!("Failed to emit data event: {e}");
1617 }
1618 }
1619 }
1620 NautilusWsMessage::Deltas(deltas) => {
1621 let data = Data::BookDeltas(Box::new(deltas));
1622 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
1623 log::error!("Failed to emit data event: {e}");
1624 }
1625 }
1626 NautilusWsMessage::FundingRates(updates) => {
1627 emit_funding_rates(data_sender, updates);
1628 }
1629 NautilusWsMessage::Instrument(instrument, status) => {
1630 instruments_by_symbol.insert(instrument.symbol().inner(), *instrument);
1631
1632 if let Some(status) = status
1633 && let Err(e) = data_sender.send(DataEvent::InstrumentStatus(status))
1634 {
1635 log::error!("Failed to emit instrument status event: {e}");
1636 }
1637 }
1638 NautilusWsMessage::InstrumentStatus(status) => {
1639 if let Err(e) = data_sender.send(DataEvent::InstrumentStatus(status)) {
1640 log::error!("Failed to emit instrument status event: {e}");
1641 }
1642 }
1643 _ => {}
1644 }
1645}
1646
1647fn emit_funding_rates(sender: &EventSender<DataEvent>, updates: Vec<FundingRateUpdate>) {
1648 for update in updates {
1649 if let Err(e) = sender.send(DataEvent::FundingRate(update)) {
1650 log::error!("Failed to emit funding rate event: {e}");
1651 }
1652 }
1653}
1654
1655fn emit_instrument_status(
1656 sender: &EventSender<DataEvent>,
1657 instrument_id: InstrumentId,
1658 status_action: MarketStatusAction,
1659 is_live: bool,
1660 ts_init: UnixNanos,
1661) {
1662 let status = InstrumentStatus::new(
1663 instrument_id,
1664 status_action,
1665 ts_init,
1666 ts_init,
1667 None,
1668 None,
1669 Some(is_live),
1670 None,
1671 None,
1672 );
1673
1674 if let Err(e) = sender.send(DataEvent::InstrumentStatus(status)) {
1675 log::error!("Failed to emit instrument status event: {e}");
1676 }
1677}
1678
1679fn changed_definitions(
1680 fetched: &[InstrumentAny],
1681 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1682) -> Vec<InstrumentAny> {
1683 fetched
1684 .iter()
1685 .filter(|instrument| {
1686 instruments_by_symbol
1687 .get_cloned(&instrument.symbol().inner())
1688 .is_none_or(|cached| !instrument_definitions_match(&cached, instrument))
1689 })
1690 .cloned()
1691 .collect()
1692}
1693
1694fn cache_instrument_updates(
1698 changed: &[InstrumentAny],
1699 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1700 http_client: &OKXHttpClient,
1701 ws_public: Option<&OKXWebSocketClient>,
1702 ws_business: Option<&OKXWebSocketClient>,
1703 instrument_update_lock: &InstrumentUpdateLock,
1704) {
1705 if changed.is_empty() {
1706 return;
1707 }
1708
1709 instruments_by_symbol.rcu(|m| {
1710 for instrument in changed {
1711 m.insert(instrument.symbol().inner(), instrument.clone());
1712 }
1713 });
1714 http_client.cache_instruments(changed);
1715
1716 if let Some(ws) = ws_public {
1717 ws.cache_instruments(changed);
1718 }
1719
1720 if let Some(ws) = ws_business {
1721 ws.cache_instruments(changed);
1722 }
1723
1724 instrument_update_lock
1725 .write_seq
1726 .fetch_add(1, Ordering::SeqCst);
1727}
1728
1729fn publish_instrument_updates(
1734 changed: &[InstrumentAny],
1735 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1736 http_client: &OKXHttpClient,
1737 ws_public: Option<&OKXWebSocketClient>,
1738 ws_business: Option<&OKXWebSocketClient>,
1739 instrument_update_lock: &InstrumentUpdateLock,
1740 data_sender: &EventSender<DataEvent>,
1741) {
1742 cache_instrument_updates(
1743 changed,
1744 instruments_by_symbol,
1745 http_client,
1746 ws_public,
1747 ws_business,
1748 instrument_update_lock,
1749 );
1750
1751 for instrument in changed {
1752 if let Err(e) = data_sender.send(DataEvent::Instrument(instrument.clone())) {
1753 log::error!("Failed to emit instrument event: {e}");
1754 }
1755 }
1756}
1757
1758fn contract_filter_with_config(config: &OKXDataClientConfig, instrument: &InstrumentAny) -> bool {
1759 contract_filter_with_config_types(config.contract_types.as_ref(), instrument)
1760}
1761
1762fn definition_in_scope(
1767 config: &OKXDataClientConfig,
1768 okx_inst: &OKXInstrument,
1769 instrument: &InstrumentAny,
1770) -> bool {
1771 if !contract_filter_with_config(config, instrument) {
1772 return false;
1773 }
1774
1775 let Some(families) = &config.instrument_families else {
1776 return true;
1777 };
1778
1779 if families.is_empty()
1780 || !matches!(
1781 okx_inst.inst_type,
1782 OKXInstrumentType::Option
1783 | OKXInstrumentType::Futures
1784 | OKXInstrumentType::Swap
1785 | OKXInstrumentType::Events
1786 )
1787 {
1788 return true;
1789 }
1790
1791 let family_key = if matches!(okx_inst.inst_type, OKXInstrumentType::Events) {
1792 okx_inst.series_id.map(|series| series.as_str())
1795 } else {
1796 Some(okx_inst.inst_family.as_str())
1797 };
1798
1799 let Some(family_key) = family_key else {
1800 return false;
1801 };
1802
1803 families.iter().any(|family| family.as_str() == family_key)
1804}
1805
1806fn contract_filter_with_config_types(
1807 contract_types: Option<&Vec<OKXContractType>>,
1808 instrument: &InstrumentAny,
1809) -> bool {
1810 match contract_types {
1811 None => true,
1812 Some(filter) if filter.is_empty() => true,
1813 Some(filter) => {
1814 let is_inverse = instrument.is_inverse();
1815 (is_inverse && filter.contains(&OKXContractType::Inverse))
1816 || (!is_inverse && filter.contains(&OKXContractType::Linear))
1817 }
1818 }
1819}
1820
1821fn configured_instrument_types(config: &OKXDataClientConfig) -> Vec<OKXInstrumentType> {
1822 if config.instrument_types.is_empty() {
1823 vec![OKXInstrumentType::Spot]
1824 } else {
1825 let mut seen = AHashSet::new();
1827 config
1828 .instrument_types
1829 .iter()
1830 .filter(|inst_type| seen.insert(**inst_type))
1831 .copied()
1832 .collect()
1833 }
1834}
1835
1836async fn fetch_configured_instruments(
1842 http_client: &OKXHttpClient,
1843 config: &OKXDataClientConfig,
1844) -> anyhow::Result<Vec<InstrumentAny>> {
1845 let instrument_types = configured_instrument_types(config);
1846 let mut all_instruments = Vec::new();
1847
1848 for inst_type in &instrument_types {
1849 let Some(mut families) =
1850 resolve_instrument_families(&config.instrument_families, *inst_type)
1851 else {
1852 continue;
1853 };
1854
1855 let mut seen = AHashSet::new();
1857 families.retain(|family| seen.insert(family.clone()));
1858
1859 if families.is_empty() {
1860 let (mut fetched, _inst_id_codes) = http_client
1861 .request_instruments(*inst_type, None)
1862 .await
1863 .with_context(|| format!("failed to request OKX instruments for {inst_type:?}"))?;
1864
1865 fetched.retain(|instrument| contract_filter_with_config(config, instrument));
1866 all_instruments.extend(fetched);
1867 } else {
1868 for family in &families {
1869 let (mut fetched, _inst_id_codes) = http_client
1870 .request_instruments(*inst_type, Some(family.clone()))
1871 .await
1872 .with_context(|| {
1873 format!(
1874 "failed to request OKX instruments for {inst_type:?} family {family}"
1875 )
1876 })?;
1877
1878 fetched.retain(|instrument| contract_filter_with_config(config, instrument));
1879 all_instruments.extend(fetched);
1880 }
1881 }
1882 }
1883
1884 if config.load_spreads {
1885 match http_client
1886 .request_spread_instruments(GetSpreadsParams {
1887 state: Some("live".to_string()),
1888 ..Default::default()
1889 })
1890 .await
1891 {
1892 Ok(mut fetched) => {
1893 fetched.retain(|instrument| contract_filter_with_config(config, instrument));
1894 all_instruments.extend(fetched);
1895 }
1896 Err(e) => {
1897 log::error!("Failed to fetch OKX spread instruments: {e:?}");
1898 }
1899 }
1900 }
1901
1902 Ok(all_instruments)
1903}
1904
1905fn instrument_definitions_match(a: &InstrumentAny, b: &InstrumentAny) -> bool {
1911 fn normalized(instrument: &InstrumentAny) -> Option<serde_json::Value> {
1912 let mut value = serde_json::to_value(instrument).ok()?;
1913
1914 if let Some(definition) = value
1915 .as_object_mut()
1916 .and_then(|obj| obj.values_mut().next())
1917 .and_then(serde_json::Value::as_object_mut)
1918 {
1919 definition.remove("ts_event");
1920 definition.remove("ts_init");
1921 }
1922
1923 Some(value)
1924 }
1925
1926 match (normalized(a), normalized(b)) {
1928 (Some(a), Some(b)) => a == b,
1929 _ => false,
1930 }
1931}
1932
1933#[derive(Debug)]
1935struct InstrumentReconciliation {
1936 fetched: usize,
1938 changed: usize,
1940 missing: usize,
1942}
1943
1944async fn reconcile_instruments(
1954 http_client: &OKXHttpClient,
1955 config: &OKXDataClientConfig,
1956 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1957 instrument_update_lock: &InstrumentUpdateLock,
1958 ws_public: Option<&OKXWebSocketClient>,
1959 ws_business: Option<&OKXWebSocketClient>,
1960 data_sender: &EventSender<DataEvent>,
1961) -> anyhow::Result<InstrumentReconciliation> {
1962 let seq_before = instrument_update_lock.write_seq.load(Ordering::SeqCst);
1963 let fetched = fetch_configured_instruments(http_client, config).await?;
1964
1965 let _update_guard = instrument_update_lock.mutex.lock();
1968
1969 let changed = if instrument_update_lock.write_seq.load(Ordering::SeqCst) == seq_before {
1972 changed_definitions(&fetched, instruments_by_symbol)
1973 } else {
1974 log::debug!("OKX instrument cache changed during refresh fetch, skipping publish");
1975 Vec::new()
1976 };
1977
1978 if !changed.is_empty() {
1979 publish_instrument_updates(
1980 &changed,
1981 instruments_by_symbol,
1982 http_client,
1983 ws_public,
1984 ws_business,
1985 instrument_update_lock,
1986 data_sender,
1987 );
1988 }
1989
1990 let fetched_symbols: AHashSet<Ustr> = fetched
1991 .iter()
1992 .map(|instrument| instrument.symbol().inner())
1993 .collect();
1994 let missing = instruments_by_symbol
1995 .load()
1996 .keys()
1997 .filter(|symbol| !fetched_symbols.contains(*symbol))
1998 .count();
1999
2000 if missing > 0 {
2001 log::debug!(
2002 "{missing} cached instruments absent from OKX REST response, retaining cached definitions"
2003 );
2004 }
2005
2006 Ok(InstrumentReconciliation {
2007 fetched: fetched.len(),
2008 changed: changed.len(),
2009 missing,
2010 })
2011}
2012
2013#[async_trait::async_trait(?Send)]
2014impl DataClient for OKXDataClient {
2015 fn client_id(&self) -> ClientId {
2016 self.client_id
2017 }
2018
2019 fn venue(&self) -> Option<Venue> {
2020 Some(self.venue())
2021 }
2022
2023 fn start(&mut self) -> anyhow::Result<()> {
2024 log::info!(
2025 "Started: client_id={}, vip_level={:?}, instrument_types={:?}, environment={}, proxy_url={:?}",
2026 self.client_id,
2027 self.vip_level(),
2028 self.config.instrument_types,
2029 self.config.environment,
2030 self.config.proxy_url,
2031 );
2032 Ok(())
2033 }
2034
2035 fn stop(&mut self) -> anyhow::Result<()> {
2036 log::info!("Stopping {id}", id = self.client_id);
2037 self.begin_generation_shutdown();
2038 Ok(())
2039 }
2040
2041 fn reset(&mut self) -> anyhow::Result<()> {
2042 log::debug!("Resetting {id}", id = self.client_id);
2043 self.begin_generation_shutdown();
2044 self.book_deltas.store(AHashSet::new());
2045 self.book_depths.store(AHashMap::new());
2046 self.book_channels.store(AHashMap::new());
2047 self.book_sync.clear();
2048 self.option_greeks_subs
2049 .store(AHashMap::<InstrumentId, AHashSet<OKXGreeksType>>::new());
2050 self.option_summary_family_subs.lock().clear();
2051 Ok(())
2052 }
2053
2054 fn dispose(&mut self) -> anyhow::Result<()> {
2055 log::debug!("Disposing {id}", id = self.client_id);
2056 self.begin_generation_shutdown();
2057 Ok(())
2058 }
2059
2060 async fn connect(&mut self) -> anyhow::Result<()> {
2061 if self.is_connected() && self.tasks.is_open() {
2062 return Ok(());
2063 }
2064
2065 let ws_public = self.ws_public.clone();
2066 let ws_business = self.ws_business.clone();
2067 let setup_guard = TaskGroupGuard::new(&[&self.tasks], move || {
2068 if let Some(ws) = ws_public {
2069 ws.begin_shutdown();
2070 }
2071
2072 if let Some(ws) = ws_business {
2073 ws.begin_shutdown();
2074 }
2075 });
2076
2077 if let Err(e) = self.connect_session().await {
2078 if let Err(teardown_error) = self.teardown_transports().await {
2079 return Err(e.context(format!(
2080 "OKX data startup teardown failed: {teardown_error}"
2081 )));
2082 }
2083 return Err(e);
2084 }
2085
2086 self.is_connected.store(true, Ordering::Release);
2087 setup_guard.disarm();
2088 log::info!("Connected: client_id={}", self.client_id);
2089 Ok(())
2090 }
2091
2092 async fn disconnect(&mut self) -> anyhow::Result<()> {
2093 if self.is_disconnected()
2094 && !self.transports_started
2095 && self.tasks.is_empty()
2096 && self.ws_public.as_ref().is_none_or(|ws| !ws.has_task())
2097 && self.ws_business.as_ref().is_none_or(|ws| !ws.has_task())
2098 {
2099 return Ok(());
2100 }
2101
2102 if !self.is_disconnected() {
2103 if let Some(ref ws) = self.ws_public
2104 && let Err(e) = ws.unsubscribe_all().await
2105 {
2106 log::warn!("Failed to unsubscribe all from public websocket: {e:?}");
2107 }
2108
2109 if let Some(ref ws) = self.ws_business
2110 && let Err(e) = ws.unsubscribe_all().await
2111 {
2112 log::warn!("Failed to unsubscribe all from business websocket: {e:?}");
2113 }
2114
2115 time::sleep(Duration::from_millis(500)).await;
2117 }
2118
2119 self.begin_generation_shutdown();
2120 self.teardown_transports().await?;
2121 log::info!("Disconnected: client_id={}", self.client_id);
2122 Ok(())
2123 }
2124
2125 fn is_connected(&self) -> bool {
2126 self.is_connected.load(Ordering::Relaxed)
2127 }
2128
2129 fn is_disconnected(&self) -> bool {
2130 !self.is_connected()
2131 }
2132
2133 fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
2134 for inst_type in &self.config.instrument_types {
2135 let ws = self.public_ws()?.clone();
2136 let inst_type = *inst_type;
2137
2138 self.spawn_ws(
2139 async move {
2140 ws.subscribe_instruments(inst_type)
2141 .await
2142 .context("instruments subscription")?;
2143 Ok(())
2144 },
2145 "subscribe_instruments",
2146 );
2147 }
2148 Ok(())
2149 }
2150
2151 fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
2152 let instrument_id = cmd.instrument_id;
2155 let ws = self.public_ws()?.clone();
2156
2157 self.spawn_ws(
2158 async move {
2159 ws.subscribe_instrument(instrument_id)
2160 .await
2161 .context("instrument type subscription")?;
2162 Ok(())
2163 },
2164 "subscribe_instrument",
2165 );
2166 Ok(())
2167 }
2168
2169 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
2170 anyhow::ensure!(
2171 cmd.book_type == BookType::L2_MBP,
2172 "OKX only supports L2_MBP order books"
2173 );
2174 let channel = self.book_channel(cmd.instrument_id, cmd.depth, cmd.params.as_ref());
2175 let was_subscribed = self.book_deltas.contains(&cmd.instrument_id);
2176 self.book_deltas.insert(cmd.instrument_id);
2177
2178 if let Err(e) = self.subscribe_book_channel(cmd.instrument_id, channel) {
2179 if !was_subscribed {
2180 self.book_deltas.remove(&cmd.instrument_id);
2181 }
2182
2183 return Err(e);
2184 }
2185
2186 Ok(())
2187 }
2188
2189 fn subscribe_book_depth(&mut self, cmd: SubscribeBookDepth) -> anyhow::Result<()> {
2190 anyhow::ensure!(
2191 cmd.book_type == BookType::L2_MBP,
2192 "OKX only supports L2_MBP order books"
2193 );
2194 let depth = cmd
2195 .depth
2196 .map_or(BOOK_SNAPSHOT_DEPTH, std::num::NonZero::get);
2197 anyhow::ensure!(
2198 depth <= BOOK_SNAPSHOT_DEPTH,
2199 "OKX depth snapshots support at most {BOOK_SNAPSHOT_DEPTH} levels"
2200 );
2201 anyhow::ensure!(
2202 !cmd.params
2203 .as_ref()
2204 .and_then(|params| params.get_bool("rpi"))
2205 .unwrap_or(false),
2206 "OKX native depth snapshots do not support RPI"
2207 );
2208
2209 if let Some(active) = self.book_depths.get_cloned(&cmd.instrument_id) {
2210 anyhow::ensure!(
2211 active == depth,
2212 "Conflicting OKX snapshot depth for {}",
2213 cmd.instrument_id
2214 );
2215 return Ok(());
2216 }
2217
2218 let instrument_id = cmd.instrument_id;
2219 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
2220 self.book_depths.insert(instrument_id, depth);
2221 if let Err(e) = self.subscribe_book_channel(instrument_id, OKXBookChannel::SprdBooks5) {
2222 self.book_depths.remove(&instrument_id);
2223 return Err(e);
2224 }
2225 } else {
2226 let ws = self.public_ws()?.clone();
2227 self.book_depths.insert(instrument_id, depth);
2228 self.spawn_ws(
2229 async move {
2230 ws.subscribe_book_depth5(instrument_id)
2231 .await
2232 .context("book depth subscription")
2233 },
2234 "book depth subscription",
2235 );
2236 }
2237
2238 Ok(())
2239 }
2240
2241 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
2242 let instrument_id = cmd.instrument_id;
2243
2244 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
2245 let ws = self.business_ws()?.clone();
2246 self.spawn_ws(
2247 async move {
2248 ws.subscribe_spread_quotes(instrument_id)
2249 .await
2250 .context("spread quotes subscription")
2251 },
2252 "spread quote subscription",
2253 );
2254 return Ok(());
2255 }
2256
2257 let ws = self.public_ws()?.clone();
2258 self.spawn_ws(
2259 async move {
2260 ws.subscribe_quotes(instrument_id)
2261 .await
2262 .context("quotes subscription")
2263 },
2264 "quote subscription",
2265 );
2266 Ok(())
2267 }
2268
2269 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
2270 let instrument_id = cmd.instrument_id;
2271
2272 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
2273 let ws = self.business_ws()?.clone();
2274 self.spawn_ws(
2275 async move {
2276 ws.subscribe_spread_trades(instrument_id)
2277 .await
2278 .context("spread trades subscription")
2279 },
2280 "spread trade subscription",
2281 );
2282 return Ok(());
2283 }
2284
2285 let ws = self.public_ws()?.clone();
2286 self.spawn_ws(
2287 async move {
2288 ws.subscribe_trades(instrument_id, false)
2289 .await
2290 .context("trades subscription")
2291 },
2292 "trade subscription",
2293 );
2294 Ok(())
2295 }
2296
2297 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
2298 let ws = self.public_ws()?.clone();
2299 let instrument_id = cmd.instrument_id;
2300
2301 self.spawn_ws(
2302 async move {
2303 ws.subscribe_mark_prices(instrument_id)
2304 .await
2305 .context("mark price subscription")
2306 },
2307 "mark price subscription",
2308 );
2309 Ok(())
2310 }
2311
2312 fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
2313 let ws = self.public_ws()?.clone();
2314 let instrument_id = cmd.instrument_id;
2315 let symbol = instrument_id.symbol.inner();
2316
2317 let (base, quote) = parse_base_quote_from_symbol(symbol.as_str())?;
2318 let base_pair = Ustr::from(&format!("{base}-{quote}"));
2319 self.index_ticker_map.rcu(|m| {
2320 m.entry(base_pair).or_default().insert(symbol);
2321 });
2322
2323 self.spawn_ws(
2324 async move {
2325 ws.subscribe_index_prices(instrument_id)
2326 .await
2327 .context("index price subscription")
2328 },
2329 "index price subscription",
2330 );
2331 Ok(())
2332 }
2333
2334 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
2335 let ws = self.business_ws()?.clone();
2336 let bar_type = cmd.bar_type;
2337
2338 self.spawn_ws(
2339 async move {
2340 ws.subscribe_bars(bar_type)
2341 .await
2342 .context("bars subscription")
2343 },
2344 "bar subscription",
2345 );
2346 Ok(())
2347 }
2348
2349 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
2350 let ws = self.public_ws()?.clone();
2351 let instrument_id = cmd.instrument_id;
2352
2353 self.spawn_ws(
2354 async move {
2355 ws.subscribe_funding_rates(instrument_id)
2356 .await
2357 .context("funding rate subscription")
2358 },
2359 "funding rate subscription",
2360 );
2361 Ok(())
2362 }
2363
2364 fn subscribe_option_greeks(&mut self, cmd: SubscribeOptionGreeks) -> anyhow::Result<()> {
2365 let instrument_id = cmd.instrument_id;
2366 let conventions = parse_greeks_conventions_from_params(cmd.params.as_ref());
2367 self.option_greeks_subs.insert(instrument_id, conventions);
2368
2369 let family = extract_inst_family(instrument_id.symbol.inner().as_str())?;
2370 let is_first = {
2371 let mut family_subs = self.option_summary_family_subs.lock();
2372 let count = family_subs.entry(family).or_default();
2373 *count += 1;
2374 *count == 1
2375 };
2376
2377 if is_first {
2378 let ws = self.public_ws()?.clone();
2379 let family_subs = self.option_summary_family_subs.clone();
2380 self.spawn_ws(
2381 async move {
2382 let result = ws
2383 .subscribe_option_summary(family)
2384 .await
2385 .context("opt-summary subscription");
2386
2387 if result.is_err() {
2388 let mut subs = family_subs.lock();
2391
2392 if let Some(count) = subs.get_mut(&family) {
2393 *count = count.saturating_sub(1);
2394 if *count == 0 {
2395 subs.remove(&family);
2396 }
2397 }
2398 }
2399 result
2400 },
2401 "option greeks subscription",
2402 );
2403 }
2404 Ok(())
2405 }
2406
2407 fn subscribe_instrument_status(
2408 &mut self,
2409 cmd: SubscribeInstrumentStatus,
2410 ) -> anyhow::Result<()> {
2411 let ws = self.public_ws()?.clone();
2412 let instrument_id = cmd.instrument_id;
2413
2414 self.spawn_ws(
2415 async move {
2416 ws.subscribe_instrument(instrument_id)
2417 .await
2418 .context("instrument status subscription")
2419 },
2420 "instrument status subscription",
2421 );
2422 Ok(())
2423 }
2424
2425 fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
2426 let instrument_id = cmd.instrument_id;
2427 let ws = self.public_ws()?.clone();
2428
2429 self.spawn_ws(
2430 async move {
2431 ws.unsubscribe_instrument(instrument_id)
2432 .await
2433 .context("instrument unsubscribe")?;
2434 Ok(())
2435 },
2436 "unsubscribe_instrument",
2437 );
2438 Ok(())
2439 }
2440
2441 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
2442 if !self.book_deltas.contains(&cmd.instrument_id) {
2443 return Ok(());
2444 }
2445
2446 if !is_okx_spread_symbol(cmd.instrument_id.symbol.as_str())
2447 || !self.book_depths.contains_key(&cmd.instrument_id)
2448 {
2449 self.unsubscribe_book_channel(cmd.instrument_id)?;
2450 }
2451
2452 self.book_deltas.remove(&cmd.instrument_id);
2453 Ok(())
2454 }
2455
2456 fn unsubscribe_book_depth(&mut self, cmd: &UnsubscribeBookDepth) -> anyhow::Result<()> {
2457 if !self.book_depths.contains_key(&cmd.instrument_id) {
2458 return Ok(());
2459 }
2460
2461 let instrument_id = cmd.instrument_id;
2462 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
2463 if !self.book_deltas.contains(&instrument_id) {
2464 self.unsubscribe_book_channel(instrument_id)?;
2465 }
2466 } else {
2467 let ws = self.public_ws()?.clone();
2468 self.spawn_ws(
2469 async move {
2470 ws.unsubscribe_book_depth5(instrument_id)
2471 .await
2472 .context("book depth unsubscribe")
2473 },
2474 "book depth unsubscribe",
2475 );
2476 }
2477
2478 self.book_depths.remove(&cmd.instrument_id);
2479 Ok(())
2480 }
2481
2482 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
2483 let instrument_id = cmd.instrument_id;
2484
2485 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
2486 let ws = self.business_ws()?.clone();
2487 self.spawn_ws(
2488 async move {
2489 ws.unsubscribe_spread_quotes(instrument_id)
2490 .await
2491 .context("spread quotes unsubscribe")
2492 },
2493 "spread quote unsubscribe",
2494 );
2495 return Ok(());
2496 }
2497
2498 let ws = self.public_ws()?.clone();
2499 self.spawn_ws(
2500 async move {
2501 ws.unsubscribe_quotes(instrument_id)
2502 .await
2503 .context("quotes unsubscribe")
2504 },
2505 "quote unsubscribe",
2506 );
2507 Ok(())
2508 }
2509
2510 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
2511 let instrument_id = cmd.instrument_id;
2512
2513 if is_okx_spread_symbol(instrument_id.symbol.as_str()) {
2514 let ws = self.business_ws()?.clone();
2515 self.spawn_ws(
2516 async move {
2517 ws.unsubscribe_spread_trades(instrument_id)
2518 .await
2519 .context("spread trades unsubscribe")
2520 },
2521 "spread trade unsubscribe",
2522 );
2523 return Ok(());
2524 }
2525
2526 let ws = self.public_ws()?.clone();
2527 self.spawn_ws(
2528 async move {
2529 ws.unsubscribe_trades(instrument_id, false) .await
2531 .context("trades unsubscribe")
2532 },
2533 "trade unsubscribe",
2534 );
2535 Ok(())
2536 }
2537
2538 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
2539 let ws = self.public_ws()?.clone();
2540 let instrument_id = cmd.instrument_id;
2541
2542 self.spawn_ws(
2543 async move {
2544 ws.unsubscribe_mark_prices(instrument_id)
2545 .await
2546 .context("mark price unsubscribe")
2547 },
2548 "mark price unsubscribe",
2549 );
2550 Ok(())
2551 }
2552
2553 fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
2554 let ws = self.public_ws()?.clone();
2555 let instrument_id = cmd.instrument_id;
2556 let symbol = instrument_id.symbol.inner();
2557
2558 if let Ok((base, quote)) = parse_base_quote_from_symbol(symbol.as_str()) {
2565 let base_pair = Ustr::from(&format!("{base}-{quote}"));
2566 self.index_ticker_map.rcu(|m| {
2567 if let Some(set) = m.get_mut(&base_pair) {
2568 set.remove(&symbol);
2569 if set.is_empty() {
2570 m.remove(&base_pair);
2571 }
2572 }
2573 });
2574 }
2575
2576 self.spawn_ws(
2577 async move {
2578 ws.unsubscribe_index_prices(instrument_id)
2579 .await
2580 .context("index price unsubscribe")
2581 },
2582 "index price unsubscribe",
2583 );
2584 Ok(())
2585 }
2586
2587 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
2588 let ws = self.business_ws()?.clone();
2589 let bar_type = cmd.bar_type;
2590
2591 self.spawn_ws(
2592 async move {
2593 ws.unsubscribe_bars(bar_type)
2594 .await
2595 .context("bars unsubscribe")
2596 },
2597 "bar unsubscribe",
2598 );
2599 Ok(())
2600 }
2601
2602 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
2603 let ws = self.public_ws()?.clone();
2604 let instrument_id = cmd.instrument_id;
2605
2606 self.spawn_ws(
2607 async move {
2608 ws.unsubscribe_funding_rates(instrument_id)
2609 .await
2610 .context("funding rate unsubscribe")
2611 },
2612 "funding rate unsubscribe",
2613 );
2614 Ok(())
2615 }
2616
2617 fn unsubscribe_option_greeks(&mut self, cmd: &UnsubscribeOptionGreeks) -> anyhow::Result<()> {
2618 let instrument_id = cmd.instrument_id;
2619 self.option_greeks_subs.remove(&instrument_id);
2620
2621 let family = extract_inst_family(instrument_id.symbol.inner().as_str())?;
2622 let should_unsubscribe = {
2623 let mut family_subs = self.option_summary_family_subs.lock();
2624
2625 if let Some(count) = family_subs.get_mut(&family) {
2626 *count = count.saturating_sub(1);
2627 if *count == 0 {
2628 family_subs.remove(&family);
2629 true
2630 } else {
2631 false
2632 }
2633 } else {
2634 false
2635 }
2636 };
2637
2638 if should_unsubscribe {
2639 let ws = self.public_ws()?.clone();
2640 self.spawn_ws(
2641 async move {
2642 ws.unsubscribe_option_summary(family)
2643 .await
2644 .context("opt-summary unsubscription")
2645 },
2646 "option greeks unsubscription",
2647 );
2648 }
2649 Ok(())
2650 }
2651
2652 fn unsubscribe_instrument_status(
2653 &mut self,
2654 cmd: &UnsubscribeInstrumentStatus,
2655 ) -> anyhow::Result<()> {
2656 let ws = self.public_ws()?.clone();
2657 let instrument_id = cmd.instrument_id;
2658
2659 self.spawn_ws(
2660 async move {
2661 ws.unsubscribe_instrument(instrument_id)
2662 .await
2663 .context("instrument status unsubscription")
2664 },
2665 "instrument status unsubscription",
2666 );
2667 Ok(())
2668 }
2669
2670 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
2671 let http = self.http_client.clone();
2672 let sender = self.data_sender.clone();
2673 let instruments_cache = self.instruments_by_symbol.clone();
2674 let update_lock = self.instrument_update_lock.clone();
2675 let ws_public = self.ws_public.clone();
2676 let ws_business = self.ws_business.clone();
2677 let request_id = request.request_id;
2678 let client_id = request.client_id.unwrap_or(self.client_id);
2679 let venue = self.venue();
2680 let start = request.start;
2681 let end = request.end;
2682 let params = request.params;
2683 let clock = self.clock;
2684 let start_nanos = datetime_to_unix_nanos(start);
2685 let end_nanos = datetime_to_unix_nanos(end);
2686 let instrument_types = configured_instrument_types(&self.config);
2687 let contract_types = self.config.contract_types.clone();
2688 let instrument_families = self.config.instrument_families.clone();
2689 let load_spreads = self.config.load_spreads;
2690
2691 self.spawn_task(async move {
2692 let seq_before = update_lock.write_seq.load(Ordering::SeqCst);
2693 let mut all_instruments = Vec::new();
2694
2695 for inst_type in instrument_types {
2696 let Some(families) = resolve_instrument_families(&instrument_families, inst_type)
2697 else {
2698 continue;
2699 };
2700
2701 if families.is_empty() {
2702 match http.request_instruments(inst_type, None).await {
2703 Ok((instruments, _inst_id_codes)) => {
2704 for instrument in instruments {
2705 if !contract_filter_with_config_types(
2706 contract_types.as_ref(),
2707 &instrument,
2708 ) {
2709 continue;
2710 }
2711
2712 all_instruments.push(instrument);
2713 }
2714 }
2715 Err(e) => {
2716 log::error!("Failed to fetch instruments for {inst_type:?}: {e:?}");
2717 }
2718 }
2719 } else {
2720 for family in families {
2721 match http
2722 .request_instruments(inst_type, Some(family.clone()))
2723 .await
2724 {
2725 Ok((instruments, _inst_id_codes)) => {
2726 for instrument in instruments {
2727 if !contract_filter_with_config_types(
2728 contract_types.as_ref(),
2729 &instrument,
2730 ) {
2731 continue;
2732 }
2733
2734 all_instruments.push(instrument);
2735 }
2736 }
2737 Err(e) => {
2738 log::error!(
2739 "Failed to fetch instruments for {inst_type:?} family {family}: {e:?}"
2740 );
2741 }
2742 }
2743 }
2744 }
2745 }
2746
2747 if load_spreads {
2748 match http
2749 .request_spread_instruments(GetSpreadsParams {
2750 state: Some("live".to_string()),
2751 ..Default::default()
2752 })
2753 .await
2754 {
2755 Ok(instruments) => {
2756 for instrument in instruments {
2757 if !contract_filter_with_config_types(
2758 contract_types.as_ref(),
2759 &instrument,
2760 ) {
2761 continue;
2762 }
2763
2764 all_instruments.push(instrument);
2765 }
2766 }
2767 Err(e) => {
2768 log::error!("Failed to fetch OKX spread instruments: {e:?}");
2769 }
2770 }
2771 }
2772
2773 {
2774 let _update_guard = update_lock
2775 .mutex
2776 .lock();
2777
2778 if update_lock.write_seq.load(Ordering::SeqCst) == seq_before {
2779 cache_instrument_updates(
2780 &all_instruments,
2781 &instruments_cache,
2782 &http,
2783 ws_public.as_ref(),
2784 ws_business.as_ref(),
2785 &update_lock,
2786 );
2787 } else {
2788 log::debug!(
2789 "OKX instrument cache changed during request fetch, skipping cache update"
2790 );
2791 }
2792 }
2793
2794 let response = DataResponse::Instruments(InstrumentsResponse::new(
2795 request_id,
2796 client_id,
2797 venue,
2798 all_instruments,
2799 start_nanos,
2800 end_nanos,
2801 clock.get_time_ns(),
2802 params,
2803 ));
2804
2805 if let Err(e) = sender.send(DataEvent::Response(response)) {
2806 log::error!("Failed to send instruments response: {e}");
2807 }
2808 });
2809
2810 Ok(())
2811 }
2812
2813 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
2814 let http = self.http_client.clone();
2815 let sender = self.data_sender.clone();
2816 let instruments = self.instruments_by_symbol.clone();
2817 let update_lock = self.instrument_update_lock.clone();
2818 let ws_public = self.ws_public.clone();
2819 let ws_business = self.ws_business.clone();
2820 let instrument_id = request.instrument_id;
2821 let request_id = request.request_id;
2822 let client_id = request.client_id.unwrap_or(self.client_id);
2823 let start = request.start;
2824 let end = request.end;
2825 let params = request.params;
2826 let clock = self.clock;
2827 let start_nanos = datetime_to_unix_nanos(start);
2828 let end_nanos = datetime_to_unix_nanos(end);
2829 let instrument_types = configured_instrument_types(&self.config);
2830 let contract_types = self.config.contract_types.clone();
2831 let load_spreads = self.config.load_spreads;
2832
2833 self.spawn_task(async move {
2834 let seq_before = update_lock.write_seq.load(Ordering::SeqCst);
2835
2836 match http
2837 .request_instrument(instrument_id)
2838 .await
2839 .context("fetch instrument from API")
2840 {
2841 Ok(instrument) => {
2842 let inst_id = instrument.id();
2843 let symbol = inst_id.symbol.as_str();
2844 if is_okx_spread_symbol(symbol) {
2845 if !load_spreads {
2846 log::error!(
2847 "Instrument {instrument_id} is a spread but load_spreads is false"
2848 );
2849 return;
2850 }
2851 } else {
2852 let inst_type = okx_instrument_type_from_symbol(symbol);
2853 if !instrument_types.contains(&inst_type) {
2854 log::error!(
2855 "Instrument {instrument_id} type {inst_type:?} not in configured types {instrument_types:?}"
2856 );
2857 return;
2858 }
2859 }
2860
2861 if !contract_filter_with_config_types(contract_types.as_ref(), &instrument) {
2862 log::error!(
2863 "Instrument {instrument_id} filtered out by contract_types config"
2864 );
2865 return;
2866 }
2867
2868 {
2869 let _update_guard = update_lock
2870 .mutex
2871 .lock();
2872
2873 if update_lock.write_seq.load(Ordering::SeqCst) == seq_before {
2874 cache_instrument_updates(
2875 std::slice::from_ref(&instrument),
2876 &instruments,
2877 &http,
2878 ws_public.as_ref(),
2879 ws_business.as_ref(),
2880 &update_lock,
2881 );
2882 } else {
2883 log::debug!(
2884 "OKX instrument cache changed during request fetch, skipping cache update"
2885 );
2886 }
2887 }
2888
2889 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
2890 request_id,
2891 client_id,
2892 instrument.id(),
2893 instrument,
2894 start_nanos,
2895 end_nanos,
2896 clock.get_time_ns(),
2897 params,
2898 )));
2899
2900 if let Err(e) = sender.send(DataEvent::Response(response)) {
2901 log::error!("Failed to send instrument response: {e}");
2902 }
2903 }
2904 Err(e) if e.downcast_ref::<OKXInstrumentDefinitionError>().is_some() => {
2905 log::warn!("Instrument request skipped: {e:?}");
2906 }
2907 Err(e) => log::error!("Instrument request failed: {e:?}"),
2908 }
2909 });
2910
2911 Ok(())
2912 }
2913
2914 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
2915 let http = self.http_client.clone();
2916 let sender = self.data_sender.clone();
2917 let instrument_id = request.instrument_id;
2918 let depth = request.depth.map(|n| n.get() as u32);
2919 let request_id = request.request_id;
2920 let client_id = request.client_id.unwrap_or(self.client_id);
2921 let params = request.params;
2922 let rpi = params
2923 .as_ref()
2924 .and_then(|params| params.get_bool("rpi"))
2925 .unwrap_or(false);
2926 let clock = self.clock;
2927
2928 self.spawn_task(async move {
2929 let result = if rpi {
2930 http.request_rpi_book_snapshot(instrument_id, depth).await
2931 } else {
2932 http.request_book_snapshot(instrument_id, depth).await
2933 };
2934
2935 match result.context("failed to request book snapshot from OKX") {
2936 Ok(book) => {
2937 let response = DataResponse::Book(BookResponse::new(
2938 request_id,
2939 client_id,
2940 instrument_id,
2941 book,
2942 None,
2943 None,
2944 clock.get_time_ns(),
2945 params,
2946 ));
2947
2948 if let Err(e) = sender.send(DataEvent::Response(response)) {
2949 log::error!("Failed to send book snapshot response: {e}");
2950 }
2951 }
2952 Err(e) => log::error!("Book snapshot request failed: {e:?}"),
2953 }
2954 });
2955
2956 Ok(())
2957 }
2958
2959 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
2960 let http = self.http_client.clone();
2961 let sender = self.data_sender.clone();
2962 let instrument_id = request.instrument_id;
2963 let start = request.start;
2964 let end = request.end;
2965 let limit = request.limit.map(|n| n.get() as u32);
2966 let request_id = request.request_id;
2967 let client_id = request.client_id.unwrap_or(self.client_id);
2968 let params = request.params;
2969 let clock = self.clock;
2970 let start_nanos = datetime_to_unix_nanos(start);
2971 let end_nanos = datetime_to_unix_nanos(end);
2972
2973 self.spawn_task(async move {
2974 match http
2975 .request_trades(instrument_id, start, end, limit)
2976 .await
2977 .context("failed to request trades from OKX")
2978 {
2979 Ok(trades) => {
2980 let response = DataResponse::Trades(TradesResponse::new(
2981 request_id,
2982 client_id,
2983 instrument_id,
2984 trades,
2985 start_nanos,
2986 end_nanos,
2987 clock.get_time_ns(),
2988 params,
2989 ));
2990
2991 if let Err(e) = sender.send(DataEvent::Response(response)) {
2992 log::error!("Failed to send trades response: {e}");
2993 }
2994 }
2995 Err(e) => log::error!("Trade request failed: {e:?}"),
2996 }
2997 });
2998
2999 Ok(())
3000 }
3001
3002 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
3003 let http = self.http_client.clone();
3004 let sender = self.data_sender.clone();
3005 let bar_type = request.bar_type;
3006 let start = request.start;
3007 let end = request.end;
3008 let limit = request.limit.map(|n| n.get() as u32);
3009 let request_id = request.request_id;
3010 let client_id = request.client_id.unwrap_or(self.client_id);
3011 let params = request.params;
3012 let clock = self.clock;
3013 let start_nanos = datetime_to_unix_nanos(start);
3014 let end_nanos = datetime_to_unix_nanos(end);
3015
3016 self.spawn_task(async move {
3017 match http
3018 .request_bars(bar_type, start, end, limit)
3019 .await
3020 .context("failed to request bars from OKX")
3021 {
3022 Ok(bars) => {
3023 let response = DataResponse::Bars(BarsResponse::new(
3024 request_id,
3025 client_id,
3026 bar_type,
3027 bars,
3028 start_nanos,
3029 end_nanos,
3030 clock.get_time_ns(),
3031 params,
3032 ));
3033
3034 if let Err(e) = sender.send(DataEvent::Response(response)) {
3035 log::error!("Failed to send bars response: {e}");
3036 }
3037 }
3038 Err(e) => log::error!("Bar request failed: {e:?}"),
3039 }
3040 });
3041
3042 Ok(())
3043 }
3044
3045 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
3046 let http = self.http_client.clone();
3047 let sender = self.data_sender.clone();
3048 let instrument_id = request.instrument_id;
3049 let start = request.start;
3050 let end = request.end;
3051 let limit = request.limit.map(|n| n.get() as u32);
3052 let request_id = request.request_id;
3053 let client_id = request.client_id.unwrap_or(self.client_id);
3054 let params = request.params;
3055 let clock = self.clock;
3056 let start_nanos = datetime_to_unix_nanos(start);
3057 let end_nanos = datetime_to_unix_nanos(end);
3058
3059 self.spawn_task(async move {
3060 match http
3061 .request_funding_rates(instrument_id, start, end, limit)
3062 .await
3063 .context("failed to request funding rates from OKX")
3064 {
3065 Ok(funding_rates) => {
3066 let response = DataResponse::FundingRates(FundingRatesResponse::new(
3067 request_id,
3068 client_id,
3069 instrument_id,
3070 funding_rates,
3071 start_nanos,
3072 end_nanos,
3073 clock.get_time_ns(),
3074 params,
3075 ));
3076
3077 if let Err(e) = sender.send(DataEvent::Response(response)) {
3078 log::error!("Failed to send funding rates response: {e}");
3079 }
3080 }
3081 Err(e) => log::error!("Funding rates request failed: {e:?}"),
3082 }
3083 });
3084
3085 Ok(())
3086 }
3087
3088 fn request_option_chain_reference_price(
3089 &self,
3090 request: RequestOptionChainReferencePrice,
3091 ) -> anyhow::Result<()> {
3092 let http = self.http_client.clone();
3093 let sender = self.data_sender.clone();
3094 let series_id = request.series_id;
3095 let instrument_id = request.instrument_id;
3096 let request_id = request.request_id;
3097 let client_id = request.client_id.unwrap_or(self.client_id);
3098 let params = request.params;
3099 let clock = self.clock;
3100
3101 self.spawn_task(async move {
3102 let price = match http
3103 .request_option_chain_reference_price(instrument_id)
3104 .await
3105 .context("failed to request option-chain reference price from OKX")
3106 {
3107 Ok(price) => price,
3108 Err(e) => {
3109 log::error!(
3110 "Option-chain reference price request failed for {series_id}: {e:?}"
3111 );
3112 None
3113 }
3114 };
3115 let response =
3116 DataResponse::OptionChainReferencePrice(OptionChainReferencePriceResponse::new(
3117 request_id,
3118 client_id,
3119 series_id,
3120 price,
3121 clock.get_time_ns(),
3122 params,
3123 ));
3124
3125 if let Err(e) = sender.send(DataEvent::Response(response)) {
3126 log::error!("Failed to send option-chain reference price response: {e}");
3127 }
3128 });
3129
3130 Ok(())
3131 }
3132}
3133
3134pub(crate) fn parse_greeks_conventions_from_params(
3142 params: Option<&Params>,
3143) -> AHashSet<OKXGreeksType> {
3144 let default_set: AHashSet<OKXGreeksType> =
3145 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect();
3146
3147 let Some(value) = params.and_then(|p| p.get("greeks_convention")) else {
3148 return default_set;
3149 };
3150
3151 let mut out = AHashSet::new();
3152 match value {
3153 serde_json::Value::String(s) => push_convention_str(&mut out, s),
3154 serde_json::Value::Array(items) => {
3155 for item in items {
3156 if let Some(s) = item.as_str() {
3157 push_convention_str(&mut out, s);
3158 } else {
3159 log::warn!("Ignoring non-string greeks_convention entry {item:?}");
3160 }
3161 }
3162 }
3163 other => {
3164 log::warn!(
3165 "Unsupported greeks_convention value {other:?}, defaulting to both conventions"
3166 );
3167 }
3168 }
3169
3170 if out.is_empty() { default_set } else { out }
3171}
3172
3173fn push_convention_str(out: &mut AHashSet<OKXGreeksType>, raw: &str) {
3174 match raw.parse::<GreeksConvention>() {
3175 Ok(convention) => {
3176 out.insert(convention.into());
3177 }
3178 Err(_) => log::warn!("Unrecognized greeks_convention {raw:?}, skipping"),
3179 }
3180}
3181
3182#[cfg(test)]
3183mod tests {
3184 use std::{collections::HashMap, net::SocketAddr, sync::Arc};
3185
3186 use axum::{Router, extract::Query, response::Json, routing::get};
3187 use nautilus_common::{live::runner::replace_data_event_sender, testing::wait_until_async};
3188 use nautilus_core::UUID4;
3189 use nautilus_model::{
3190 identifiers::Symbol,
3191 instruments::{CurrencyPair, stubs::currency_pair_btcusdt},
3192 types::{Price, Quantity},
3193 };
3194 use nautilus_network::websocket::TransportBackend;
3195 use rstest::rstest;
3196 use serde_json::{Value, json};
3197
3198 use super::*;
3199 use crate::{
3200 common::{
3201 consts::OKX_CLIENT_ID, enums::OKXEnvironment, models::OKXInstrument,
3202 testing::load_test_json,
3203 },
3204 websocket::{enums::OKXWsChannel, messages::OKXWsFrame},
3205 };
3206
3207 #[rstest]
3208 #[case(1, false)]
3209 #[case(5, false)]
3210 #[case(1, true)]
3211 #[case(5, true)]
3212 fn native_depth_replaces_snapshots_and_ignores_unsubscribed_data(
3213 currency_pair_btcusdt: CurrencyPair,
3214 #[case] limit: usize,
3215 #[case] spread: bool,
3216 ) {
3217 let instrument = InstrumentAny::CurrencyPair(currency_pair_btcusdt);
3218 let id = instrument.id();
3219 let frame: OKXWsFrame =
3220 serde_json::from_str(&load_test_json("ws_books_snapshot.json")).unwrap();
3221
3222 let OKXWsFrame::BookData { mut data, .. } = frame else {
3223 panic!("expected book data");
3224 };
3225
3226 if spread {
3227 for message in &mut data {
3228 for level in message.bids.iter_mut().chain(&mut message.asks) {
3229 level.liquidated_orders_count = level.orders_count.clone();
3230 level.orders_count = String::new();
3231 }
3232 }
3233 }
3234
3235 let subscriptions = AtomicMap::new();
3236 subscriptions.insert(id, limit);
3237 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
3238 let sender = sender.into();
3239 OKXDataClient::send_book_depth(
3240 &mut data,
3241 &instrument,
3242 &subscriptions,
3243 &sender,
3244 UnixNanos::from(103),
3245 spread,
3246 );
3247
3248 let DataEvent::Data(Data::BookDepth(initial)) = receiver.try_recv().unwrap() else {
3249 panic!("expected depth");
3250 };
3251
3252 assert_eq!(initial.instrument_id, id);
3253 assert_eq!(initial.sequence, 123_456);
3254 assert_eq!(initial.ts_event, UnixNanos::from(1_597_026_383_085_000_000));
3255 assert_eq!(initial.ts_init, UnixNanos::from(103));
3256 assert_eq!(initial.bids.len(), limit);
3257 assert_eq!(initial.asks.len(), limit);
3258 assert_eq!(initial.bid_counts.as_slice(), &[12, 1, 1, 1, 1][..limit]);
3259 assert_eq!(initial.ask_counts.as_slice(), &[13, 2, 1, 1, 1][..limit]);
3260
3261 if spread {
3262 data[0].bids[0].liquidated_orders_count = "invalid".to_string();
3263 } else {
3264 data[0].bids[0].orders_count = "invalid".to_string();
3265 }
3266
3267 OKXDataClient::send_book_depth(
3268 &mut data,
3269 &instrument,
3270 &subscriptions,
3271 &sender,
3272 UnixNanos::from(107),
3273 spread,
3274 );
3275 assert!(receiver.try_recv().is_err());
3276 data[0].bids.clear();
3277 data[0].asks.clear();
3278 data[0].seq_id = 17;
3279 OKXDataClient::send_book_depth(
3280 &mut data,
3281 &instrument,
3282 &subscriptions,
3283 &sender,
3284 UnixNanos::from(109),
3285 spread,
3286 );
3287
3288 let DataEvent::Data(Data::BookDepth(empty)) = receiver.try_recv().unwrap() else {
3289 panic!("expected empty depth");
3290 };
3291
3292 assert!(empty.bids.is_empty());
3293 assert!(empty.asks.is_empty());
3294 assert!(empty.bid_counts.is_empty());
3295 assert!(empty.ask_counts.is_empty());
3296 assert_eq!(empty.sequence, 17);
3297 assert_eq!(empty.ts_init, UnixNanos::from(109));
3298 subscriptions.remove(&id);
3299 OKXDataClient::send_book_depth(
3300 &mut data,
3301 &instrument,
3302 &subscriptions,
3303 &sender,
3304 UnixNanos::from(113),
3305 spread,
3306 );
3307 assert!(receiver.try_recv().is_err());
3308 }
3309
3310 struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
3311
3312 impl Drop for DropSignal {
3313 fn drop(&mut self) {
3314 if let Some(sender) = self.0.take() {
3315 let _ = sender.send(());
3316 }
3317 }
3318 }
3319
3320 #[derive(Clone, Copy)]
3321 enum DataTaskBoundary {
3322 Reset,
3323 Dispose,
3324 RepeatedStop,
3325 }
3326
3327 fn both() -> AHashSet<OKXGreeksType> {
3328 [OKXGreeksType::Bs, OKXGreeksType::Pa].into_iter().collect()
3329 }
3330
3331 fn only(greeks_type: OKXGreeksType) -> AHashSet<OKXGreeksType> {
3332 [greeks_type].into_iter().collect()
3333 }
3334
3335 #[rstest]
3336 fn dispatch_parsed_data_emits_instrument_status() {
3337 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
3338 let instruments_by_symbol = Arc::new(AtomicMap::new());
3339 let status = InstrumentStatus::new(
3340 InstrumentId::from("USDG-SGD.OKX"),
3341 MarketStatusAction::Trading,
3342 UnixNanos::from(1u64),
3343 UnixNanos::from(2u64),
3344 None,
3345 None,
3346 Some(true),
3347 None,
3348 None,
3349 );
3350
3351 dispatch_parsed_data(
3352 NautilusWsMessage::InstrumentStatus(status),
3353 &sender.into(),
3354 &instruments_by_symbol,
3355 );
3356
3357 match receiver.try_recv().expect("instrument status event") {
3358 DataEvent::InstrumentStatus(received) => assert_eq!(received, status),
3359 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
3360 }
3361 assert!(instruments_by_symbol.load().is_empty());
3362 }
3363
3364 #[rstest]
3365 #[case::sent_permanent(false, "60018")]
3366 #[case::unsent_permanent(true, "60018")]
3367 #[case::sent_transient(false, "60014")]
3368 #[case::unsent_transient(true, "60014")]
3369 fn rejected_book_subscription_preserves_channel_and_sync_state(
3370 #[case] sending: bool,
3371 #[case] code: &str,
3372 ) {
3373 let instrument_id = InstrumentId::from("OMI-USD.OKX");
3374 let mut pair = currency_pair_btcusdt();
3375 pair.id = instrument_id;
3376 pair.raw_symbol = Symbol::from("OMI-USD");
3377 let instrument = InstrumentAny::CurrencyPair(pair);
3378
3379 let instruments_by_symbol = Arc::new(AtomicMap::new());
3380 instruments_by_symbol.insert(Ustr::from("OMI-USD"), instrument);
3381 let book_channels = Arc::new(AtomicMap::new());
3382 book_channels.insert(instrument_id, OKXBookChannel::Book);
3383 let book_sync = BookSyncTracker::default();
3384 let subscribed_at = Instant::now()
3385 .checked_sub(Duration::from_secs(6))
3386 .expect("subscription timestamp");
3387 let gate = SnapshotGate::default();
3388 if sending {
3389 gate.lock().close();
3390 }
3391
3392 let cancel = book_sync.record_subscription(instrument_id, subscribed_at, gate.clone());
3393 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
3394 let http = offline_http_client();
3395 let update_lock = InstrumentUpdateLock::default();
3396 let mut quote_cache = QuoteCache::new();
3397 let mut funding_cache: AHashMap<Ustr, (Ustr, u64)> = AHashMap::new();
3398 let index_ticker_map = Arc::new(AtomicMap::new());
3399 let option_greeks_subs = Arc::new(AtomicMap::new());
3400 let task_group = TaskGroup::new();
3401 let tasks = task_group.spawner().expect("task spawner");
3402
3403 OKXDataClient::handle_ws_message(
3404 OKXWsMessage::SubscriptionFailed {
3405 channel: OKXWsChannel::Books,
3406 inst_id: Some(Ustr::from("OMI-USD")),
3407 code: code.to_string(),
3408 msg: "Channel does not exist".to_string(),
3409 },
3410 &sender.into(),
3411 &instruments_by_symbol,
3412 &http,
3413 &OKXDataClientConfig::default(),
3414 &update_lock,
3415 &book_channels,
3416 &book_sync,
3417 &AtomicMap::new(),
3418 &AtomicSet::new(),
3419 None,
3420 None,
3421 &mut quote_cache,
3422 &mut funding_cache,
3423 &index_ticker_map,
3424 &option_greeks_subs,
3425 BookChannelScope::Public,
3426 Duration::ZERO,
3427 &tasks,
3428 get_atomic_clock_realtime(),
3429 );
3430
3431 assert_eq!(
3432 book_channels.load().get(&instrument_id),
3433 Some(&OKXBookChannel::Book),
3434 "rejected subscription must preserve the channel selected for reconnect"
3435 );
3436 assert_eq!(
3437 book_sync
3438 .stale_books(Duration::from_secs(5), Instant::now())
3439 .len(),
3440 1,
3441 "rejected subscription must keep book synchronization state for recovery"
3442 );
3443 assert_eq!(cancel.is_cancelled(), !sending);
3444 gate.open();
3445 assert_eq!(
3446 book_sync.validate_sequence(
3447 instrument_id,
3448 true,
3449 &[(Some(-1), 42)],
3450 Duration::ZERO,
3451 Instant::now()
3452 ),
3453 if sending {
3454 BookSequenceOutcome::Accept
3455 } else {
3456 BookSequenceOutcome::Suppress
3457 },
3458 );
3459 }
3460
3461 #[rstest]
3462 fn reconnect_clears_quote_and_funding_caches() {
3463 let instrument_id = InstrumentId::from("OMI-USD.OKX");
3464 let mut pair = currency_pair_btcusdt();
3465 pair.id = instrument_id;
3466 pair.raw_symbol = Symbol::from("OMI-USD");
3467 let instrument = InstrumentAny::CurrencyPair(pair);
3468
3469 let instruments_by_symbol = Arc::new(AtomicMap::new());
3470 instruments_by_symbol.insert(Ustr::from("OMI-USD"), instrument);
3471 let book_channels = Arc::new(AtomicMap::new());
3472 let book_sync = BookSyncTracker::default();
3473 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
3474 let http = offline_http_client();
3475 let update_lock = InstrumentUpdateLock::default();
3476 let mut quote_cache = QuoteCache::new();
3477 quote_cache
3478 .process(
3479 instrument_id,
3480 Some(Price::from("1.0")),
3481 Some(Price::from("1.1")),
3482 Some(Quantity::from("1")),
3483 Some(Quantity::from("2")),
3484 UnixNanos::from(1),
3485 UnixNanos::from(2),
3486 )
3487 .expect("seed quote cache");
3488 let mut funding_cache =
3489 AHashMap::from([(Ustr::from("OMI-USD"), (Ustr::from("0.0001"), 1))]);
3490 let index_ticker_map = Arc::new(AtomicMap::new());
3491 let option_greeks_subs = Arc::new(AtomicMap::new());
3492 let task_group = TaskGroup::new();
3493 let tasks = task_group.spawner().expect("task spawner");
3494
3495 OKXDataClient::handle_ws_message(
3496 OKXWsMessage::Reconnected,
3497 &sender.clone().into(),
3498 &instruments_by_symbol,
3499 &http,
3500 &OKXDataClientConfig::default(),
3501 &update_lock,
3502 &book_channels,
3503 &book_sync,
3504 &AtomicMap::new(),
3505 &AtomicSet::new(),
3506 None,
3507 None,
3508 &mut quote_cache,
3509 &mut funding_cache,
3510 &index_ticker_map,
3511 &option_greeks_subs,
3512 BookChannelScope::Public,
3513 Duration::ZERO,
3514 &tasks,
3515 get_atomic_clock_realtime(),
3516 );
3517
3518 assert!(
3519 quote_cache.is_empty(),
3520 "reconnect must drop quotes from the previous generation"
3521 );
3522 assert!(
3523 funding_cache.is_empty(),
3524 "reconnect must drop funding rates from the previous generation"
3525 );
3526
3527 OKXDataClient::handle_ws_message(
3528 OKXWsMessage::ChannelData {
3529 channel: OKXWsChannel::BboTbt,
3530 inst_id: Some(Ustr::from("OMI-USD")),
3531 data: json!([{
3532 "asks": [{
3533 "price": "1.2",
3534 "size": "3",
3535 "liquidated_orders_count": "0",
3536 "orders_count": "1"
3537 }],
3538 "bids": [],
3539 "seqId": 1,
3540 "ts": "3"
3541 }]),
3542 },
3543 &sender.clone().into(),
3544 &instruments_by_symbol,
3545 &http,
3546 &OKXDataClientConfig::default(),
3547 &update_lock,
3548 &book_channels,
3549 &book_sync,
3550 &AtomicMap::new(),
3551 &AtomicSet::new(),
3552 None,
3553 None,
3554 &mut quote_cache,
3555 &mut funding_cache,
3556 &index_ticker_map,
3557 &option_greeks_subs,
3558 BookChannelScope::Public,
3559 Duration::ZERO,
3560 &tasks,
3561 get_atomic_clock_realtime(),
3562 );
3563
3564 assert!(
3565 receiver.try_recv().is_err(),
3566 "partial BBO after reconnect must not invent a quote from the previous generation"
3567 );
3568 assert!(quote_cache.is_empty());
3569
3570 OKXDataClient::handle_ws_message(
3571 OKXWsMessage::ChannelData {
3572 channel: OKXWsChannel::BboTbt,
3573 inst_id: Some(Ustr::from("OMI-USD")),
3574 data: json!([{
3575 "asks": [{
3576 "price": "1.2",
3577 "size": "3",
3578 "liquidated_orders_count": "0",
3579 "orders_count": "1"
3580 }],
3581 "bids": [{
3582 "price": "1.0",
3583 "size": "4",
3584 "liquidated_orders_count": "0",
3585 "orders_count": "1"
3586 }],
3587 "seqId": 2,
3588 "ts": "4"
3589 }]),
3590 },
3591 &sender.into(),
3592 &instruments_by_symbol,
3593 &http,
3594 &OKXDataClientConfig::default(),
3595 &update_lock,
3596 &book_channels,
3597 &book_sync,
3598 &AtomicMap::new(),
3599 &AtomicSet::new(),
3600 None,
3601 None,
3602 &mut quote_cache,
3603 &mut funding_cache,
3604 &index_ticker_map,
3605 &option_greeks_subs,
3606 BookChannelScope::Public,
3607 Duration::ZERO,
3608 &tasks,
3609 get_atomic_clock_realtime(),
3610 );
3611
3612 match receiver.try_recv().expect("complete BBO after reconnect") {
3613 DataEvent::Data(Data::Quote(quote)) => {
3614 assert_eq!(quote.instrument_id, instrument_id);
3615 assert_eq!(quote.bid_price, Price::from("1.0"));
3616 assert_eq!(quote.ask_price, Price::from("1.2"));
3617 assert_eq!(quote.bid_size, Quantity::from("4"));
3618 assert_eq!(quote.ask_size, Quantity::from("3"));
3619 }
3620 other => panic!("Expected DataEvent::Data(Data::Quote), was {other:?}"),
3621 }
3622 }
3623
3624 #[rstest]
3625 fn index_ticker_fan_out_emits_subscribed_symbols_in_sorted_order() {
3626 let symbols = [
3628 "BTC-USDT-SWAP",
3629 "BTC-USDT-241227",
3630 "BTC-USDT-240628",
3631 "BTC-USDT-250328",
3632 "BTC-USDT-240906",
3633 ];
3634
3635 let instruments_by_symbol = Arc::new(AtomicMap::new());
3636
3637 for symbol in symbols {
3638 let mut pair = currency_pair_btcusdt();
3639 let id = format!("{symbol}.OKX");
3640 pair.id = InstrumentId::from(id.as_str());
3641 pair.raw_symbol = Symbol::from(symbol);
3642 instruments_by_symbol.insert(Ustr::from(symbol), InstrumentAny::CurrencyPair(pair));
3643 }
3644
3645 let index_ticker_map = Arc::new(AtomicMap::new());
3646 index_ticker_map.insert(
3647 Ustr::from("BTC-USDT"),
3648 AHashSet::from_iter(symbols.iter().map(|symbol| Ustr::from(symbol))),
3649 );
3650
3651 let book_channels = Arc::new(AtomicMap::new());
3652 let book_sync = BookSyncTracker::default();
3653 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
3654 let http = offline_http_client();
3655 let update_lock = InstrumentUpdateLock::default();
3656 let mut quote_cache = QuoteCache::new();
3657 let mut funding_cache = AHashMap::new();
3658 let option_greeks_subs = Arc::new(AtomicMap::new());
3659 let task_group = TaskGroup::new();
3660 let tasks = task_group.spawner().expect("task spawner");
3661
3662 OKXDataClient::handle_ws_message(
3663 OKXWsMessage::ChannelData {
3664 channel: OKXWsChannel::IndexTickers,
3665 inst_id: Some(Ustr::from("BTC-USDT")),
3666 data: json!([{
3667 "instId": "BTC-USDT",
3668 "idxPx": "65000.1",
3669 "high24h": "66000.0",
3670 "low24h": "64000.0",
3671 "open24h": "64500.0",
3672 "sodUtc0": "64500.0",
3673 "sodUtc8": "64600.0",
3674 "ts": "1710000000000"
3675 }]),
3676 },
3677 &sender.into(),
3678 &instruments_by_symbol,
3679 &http,
3680 &OKXDataClientConfig::default(),
3681 &update_lock,
3682 &book_channels,
3683 &book_sync,
3684 &AtomicMap::new(),
3685 &AtomicSet::new(),
3686 None,
3687 None,
3688 &mut quote_cache,
3689 &mut funding_cache,
3690 &index_ticker_map,
3691 &option_greeks_subs,
3692 BookChannelScope::Public,
3693 Duration::ZERO,
3694 &tasks,
3695 get_atomic_clock_realtime(),
3696 );
3697
3698 let mut instrument_ids = Vec::new();
3699
3700 while let Ok(event) = receiver.try_recv() {
3701 match event {
3702 DataEvent::Data(Data::IndexPrice(update)) => {
3703 instrument_ids.push(update.instrument_id);
3704 }
3705 other => panic!("Expected DataEvent::Data(Data::IndexPrice), was {other:?}"),
3706 }
3707 }
3708
3709 assert_eq!(
3710 instrument_ids,
3711 [
3712 "BTC-USDT-240628.OKX",
3713 "BTC-USDT-240906.OKX",
3714 "BTC-USDT-241227.OKX",
3715 "BTC-USDT-250328.OKX",
3716 "BTC-USDT-SWAP.OKX",
3717 ]
3718 .map(InstrumentId::from)
3719 );
3720 }
3721
3722 #[rstest]
3723 fn rpi_missing_recovery_transport_requires_reconnect_before_snapshot() {
3724 let instrument_id = InstrumentId::from("OMI-USD.OKX");
3725 let mut pair = currency_pair_btcusdt();
3726 pair.id = instrument_id;
3727 pair.raw_symbol = Symbol::from("OMI-USD");
3728 pair.price_precision = 7;
3729 pair.size_precision = 3;
3730 pair.price_increment = Price::from("0.0000001");
3731 pair.size_increment = Quantity::from("0.001");
3732 let instrument = InstrumentAny::CurrencyPair(pair);
3733
3734 let instruments_by_symbol = Arc::new(AtomicMap::new());
3735 instruments_by_symbol.insert(Ustr::from("OMI-USD"), instrument);
3736 let book_channels = Arc::new(AtomicMap::new());
3737 book_channels.insert(instrument_id, OKXBookChannel::BooksRpi);
3738 let book_sync = BookSyncTracker::default();
3739 book_sync.record_subscription(instrument_id, Instant::now(), SnapshotGate::default());
3740 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
3741 let http = offline_http_client();
3742 let update_lock = InstrumentUpdateLock::default();
3743 let index_ticker_map = Arc::new(AtomicMap::new());
3744 let option_greeks_subs = Arc::new(AtomicMap::new());
3745 let task_group = TaskGroup::new();
3746 let tasks = task_group.spawner().expect("task spawner");
3747 let mut quote_cache = QuoteCache::new();
3748 let mut funding_cache = AHashMap::new();
3749
3750 let snapshot = rpi_book_message("ws_books_rpi_snapshot.json");
3751 let update = rpi_book_message("ws_books_rpi_update.json");
3752 let mut gap = rpi_book_message("ws_books_rpi_update.json");
3753 let OKXWsMessage::RpiBookData { data, .. } = &mut gap else {
3754 unreachable!()
3755 };
3756 data[0].prev_seq_id -= 1;
3757
3758 let mut handle = |message| {
3759 OKXDataClient::handle_ws_message(
3760 message,
3761 &sender.clone().into(),
3762 &instruments_by_symbol,
3763 &http,
3764 &OKXDataClientConfig::default(),
3765 &update_lock,
3766 &book_channels,
3767 &book_sync,
3768 &AtomicMap::new(),
3769 &AtomicSet::new(),
3770 None,
3771 None,
3772 &mut quote_cache,
3773 &mut funding_cache,
3774 &index_ticker_map,
3775 &option_greeks_subs,
3776 BookChannelScope::Public,
3777 Duration::ZERO,
3778 &tasks,
3779 get_atomic_clock_realtime(),
3780 );
3781 };
3782
3783 handle(snapshot);
3784 assert!(matches!(receiver.try_recv(), Ok(DataEvent::Data(_))));
3785 assert!(matches!(
3786 receiver.try_recv(),
3787 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3788 ));
3789
3790 handle(gap);
3791 handle(update);
3792 assert!(matches!(
3793 receiver.try_recv(),
3794 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3795 ));
3796
3797 let mut recovered_snapshot = rpi_book_message("ws_books_rpi_snapshot.json");
3798 let OKXWsMessage::RpiBookData { data, .. } = &mut recovered_snapshot else {
3799 unreachable!()
3800 };
3801 data[0].seq_id = 2_000;
3802 handle(rpi_book_message("ws_books_rpi_snapshot.json"));
3803 assert!(matches!(
3804 receiver.try_recv(),
3805 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3806 ));
3807
3808 handle(OKXWsMessage::Reconnected);
3809 handle(recovered_snapshot);
3810 assert!(matches!(receiver.try_recv(), Ok(DataEvent::Data(_))));
3811 assert!(matches!(
3812 receiver.try_recv(),
3813 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
3814 ));
3815 }
3816
3817 #[rstest]
3818 fn parse_conventions_returns_both_when_params_missing() {
3819 let result = parse_greeks_conventions_from_params(None);
3820 assert_eq!(result, both());
3821 }
3822
3823 #[rstest]
3824 fn parse_conventions_returns_both_when_key_absent() {
3825 let mut params = Params::new();
3826 params.insert("other_key".to_string(), json!("value"));
3827 let result = parse_greeks_conventions_from_params(Some(¶ms));
3828 assert_eq!(result, both());
3829 }
3830
3831 #[rstest]
3832 #[case("BLACK_SCHOLES", OKXGreeksType::Bs)]
3833 #[case("PRICE_ADJUSTED", OKXGreeksType::Pa)]
3834 #[case("black_scholes", OKXGreeksType::Bs)]
3835 #[case("price_adjusted", OKXGreeksType::Pa)]
3836 fn parse_conventions_accepts_single_string(#[case] raw: &str, #[case] expected: OKXGreeksType) {
3837 let mut params = Params::new();
3838 params.insert("greeks_convention".to_string(), json!(raw));
3839 let result = parse_greeks_conventions_from_params(Some(¶ms));
3840 assert_eq!(result, only(expected));
3841 }
3842
3843 #[rstest]
3844 fn parse_conventions_accepts_list_of_strings() {
3845 let mut params = Params::new();
3846 params.insert(
3847 "greeks_convention".to_string(),
3848 json!(["BLACK_SCHOLES", "PRICE_ADJUSTED"]),
3849 );
3850 let result = parse_greeks_conventions_from_params(Some(¶ms));
3851 assert_eq!(result, both());
3852 }
3853
3854 #[rstest]
3855 fn parse_conventions_accepts_single_entry_list() {
3856 let mut params = Params::new();
3857 params.insert("greeks_convention".to_string(), json!(["PRICE_ADJUSTED"]));
3858 let result = parse_greeks_conventions_from_params(Some(¶ms));
3859 assert_eq!(result, only(OKXGreeksType::Pa));
3860 }
3861
3862 #[rstest]
3863 fn parse_conventions_deduplicates_list_entries() {
3864 let mut params = Params::new();
3865 params.insert(
3866 "greeks_convention".to_string(),
3867 json!(["BLACK_SCHOLES", "black_scholes"]),
3868 );
3869 let result = parse_greeks_conventions_from_params(Some(¶ms));
3870 assert_eq!(result, only(OKXGreeksType::Bs));
3871 }
3872
3873 #[rstest]
3874 fn parse_conventions_skips_unknown_list_entries() {
3875 let mut params = Params::new();
3876 params.insert(
3877 "greeks_convention".to_string(),
3878 json!(["BOGUS", "PRICE_ADJUSTED"]),
3879 );
3880 let result = parse_greeks_conventions_from_params(Some(¶ms));
3881 assert_eq!(result, only(OKXGreeksType::Pa));
3882 }
3883
3884 #[rstest]
3885 fn parse_conventions_falls_back_to_both_on_all_unknown() {
3886 let mut params = Params::new();
3887 params.insert("greeks_convention".to_string(), json!(["BOGUS"]));
3888 let result = parse_greeks_conventions_from_params(Some(¶ms));
3889 assert_eq!(result, both());
3890 }
3891
3892 #[rstest]
3893 #[case(json!(1))]
3894 #[case(json!(null))]
3895 #[case(json!(true))]
3896 #[case(json!({"nested": "object"}))]
3897 fn parse_conventions_falls_back_on_non_string_value(#[case] value: serde_json::Value) {
3898 let mut params = Params::new();
3899 params.insert("greeks_convention".to_string(), value);
3900 let result = parse_greeks_conventions_from_params(Some(¶ms));
3901 assert_eq!(result, both());
3902 }
3903
3904 #[rstest]
3905 fn parse_conventions_falls_back_on_unknown_single_string() {
3906 let mut params = Params::new();
3907 params.insert("greeks_convention".to_string(), json!("BOGUS"));
3908 let result = parse_greeks_conventions_from_params(Some(¶ms));
3909 assert_eq!(result, both());
3910 }
3911
3912 fn rpi_book_message(filename: &str) -> OKXWsMessage {
3913 let frame: OKXWsFrame = serde_json::from_str(&load_test_json(filename)).unwrap();
3914 let OKXWsFrame::RpiBookData { arg, action, data } = frame else {
3915 panic!("expected RPI book data");
3916 };
3917 OKXWsMessage::RpiBookData { arg, action, data }
3918 }
3919
3920 fn swap_definition(tick_sz: &str) -> Value {
3921 json!({
3922 "alias": "",
3923 "baseCcy": "",
3924 "category": "1",
3925 "ctMult": "1",
3926 "ctType": "linear",
3927 "ctVal": "0.01",
3928 "ctValCcy": "BTC",
3929 "expTime": "",
3930 "instFamily": "BTC-USDT",
3931 "instId": "BTC-USDT-SWAP",
3932 "instType": "SWAP",
3933 "lever": "125",
3934 "listTime": "1611916828000",
3935 "lotSz": "1",
3936 "maxIcebergSz": "100000000.0000000000000000",
3937 "maxLmtAmt": "20000000",
3938 "maxLmtSz": "100000000",
3939 "maxMktAmt": "",
3940 "maxMktSz": "30000",
3941 "maxStopSz": "30000",
3942 "maxTriggerSz": "100000000.0000000000000000",
3943 "maxTwapSz": "100000000.0000000000000000",
3944 "minSz": "1",
3945 "optType": "",
3946 "quoteCcy": "",
3947 "ruleType": "normal",
3948 "settleCcy": "USDT",
3949 "state": "live",
3950 "stk": "",
3951 "tickSz": tick_sz,
3952 "uly": "BTC-USDT"
3953 })
3954 }
3955
3956 fn ws_instruments_message(definition: Value) -> OKXWsMessage {
3957 let instrument: OKXInstrument =
3958 serde_json::from_value(definition).expect("valid OKXInstrument");
3959 OKXWsMessage::Instruments(vec![instrument])
3960 }
3961
3962 fn offline_http_client() -> OKXHttpClient {
3963 OKXHttpClient::new(
3964 Some("http://127.0.0.1:9".to_string()),
3965 5,
3966 0,
3967 1,
3968 1,
3969 OKXEnvironment::Live,
3970 None,
3971 )
3972 .expect("http client")
3973 }
3974
3975 fn offline_ws_client() -> OKXWebSocketClient {
3976 OKXWebSocketClient::new(
3977 Some("ws://127.0.0.1:9".to_string()),
3978 None,
3979 None,
3980 None,
3981 None,
3982 Some(OKX_WS_HEARTBEAT_SECS),
3983 None,
3984 TransportBackend::default(),
3985 None,
3986 )
3987 .expect("ws client")
3988 }
3989
3990 fn handle_instruments_message(
3991 sender: &EventSender<DataEvent>,
3992 instruments_by_symbol: &Arc<AtomicMap<Ustr, InstrumentAny>>,
3993 http_client: &OKXHttpClient,
3994 config: &OKXDataClientConfig,
3995 recovery_ws: Option<&OKXWebSocketClient>,
3996 business_ws: Option<&OKXWebSocketClient>,
3997 message: OKXWsMessage,
3998 ) {
3999 let book_channels = Arc::new(AtomicMap::new());
4000 let book_sync = BookSyncTracker::default();
4001 let update_lock = InstrumentUpdateLock::default();
4002 let mut quote_cache = QuoteCache::new();
4003 let mut funding_cache = AHashMap::new();
4004 let index_ticker_map = Arc::new(AtomicMap::new());
4005 let option_greeks_subs = Arc::new(AtomicMap::new());
4006 let task_group = TaskGroup::new();
4007 let tasks = task_group.spawner().expect("task spawner");
4008
4009 OKXDataClient::handle_ws_message(
4010 message,
4011 sender,
4012 instruments_by_symbol,
4013 http_client,
4014 config,
4015 &update_lock,
4016 &book_channels,
4017 &book_sync,
4018 &AtomicMap::new(),
4019 &AtomicSet::new(),
4020 recovery_ws,
4021 business_ws,
4022 &mut quote_cache,
4023 &mut funding_cache,
4024 &index_ticker_map,
4025 &option_greeks_subs,
4026 BookChannelScope::Public,
4027 Duration::ZERO,
4028 &tasks,
4029 get_atomic_clock_realtime(),
4030 );
4031 }
4032
4033 #[rstest]
4034 fn ws_instruments_publishes_new_definition_and_status() {
4035 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4036 let instruments_by_symbol = Arc::new(AtomicMap::new());
4037 let http = offline_http_client();
4038 let ws_public = offline_ws_client();
4039 let ws_business = offline_ws_client();
4040
4041 handle_instruments_message(
4042 &sender.into(),
4043 &instruments_by_symbol,
4044 &http,
4045 &OKXDataClientConfig::default(),
4046 Some(&ws_public),
4047 Some(&ws_business),
4048 ws_instruments_message(swap_definition("0.1")),
4049 );
4050
4051 match receiver.try_recv().expect("instrument event") {
4052 DataEvent::Instrument(instrument) => {
4053 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT-SWAP.OKX"));
4054 assert_eq!(instrument.price_increment(), Price::from("0.1"));
4055 }
4056 other => panic!("Expected DataEvent::Instrument, was {other:?}"),
4057 }
4058
4059 match receiver.try_recv().expect("instrument status event") {
4060 DataEvent::InstrumentStatus(status) => {
4061 assert_eq!(
4062 status.instrument_id,
4063 InstrumentId::from("BTC-USDT-SWAP.OKX")
4064 );
4065 assert_eq!(status.action, MarketStatusAction::Trading);
4066 }
4067 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4068 }
4069 assert!(receiver.try_recv().is_err());
4070
4071 let symbol = Ustr::from("BTC-USDT-SWAP");
4072 let cached = instruments_by_symbol
4073 .get_cloned(&symbol)
4074 .expect("instrument cached in the shared cache");
4075 assert_eq!(cached.id(), InstrumentId::from("BTC-USDT-SWAP.OKX"));
4076 assert_eq!(cached.price_increment(), Price::from("0.1"));
4077 assert!(
4078 http.get_instrument(&symbol).is_some(),
4079 "HTTP client cache must be updated before publishing"
4080 );
4081 assert!(
4082 ws_public.instruments_snapshot().contains_key(&symbol),
4083 "public WebSocket cache must be updated before publishing"
4084 );
4085 assert!(
4086 ws_business.instruments_snapshot().contains_key(&symbol),
4087 "business WebSocket cache must be updated before publishing"
4088 );
4089 }
4090
4091 #[rstest]
4092 fn ws_instruments_unchanged_definition_emits_status_only() {
4093 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4094 let instruments_by_symbol = Arc::new(AtomicMap::new());
4095 let http = offline_http_client();
4096
4097 for _ in 0..2 {
4098 handle_instruments_message(
4099 &sender.clone().into(),
4100 &instruments_by_symbol,
4101 &http,
4102 &OKXDataClientConfig::default(),
4103 None,
4104 None,
4105 ws_instruments_message(swap_definition("0.1")),
4106 );
4107 }
4108
4109 assert!(matches!(receiver.try_recv(), Ok(DataEvent::Instrument(_))));
4110 assert!(matches!(
4111 receiver.try_recv(),
4112 Ok(DataEvent::InstrumentStatus(_))
4113 ));
4114
4115 match receiver
4116 .try_recv()
4117 .expect("status event for repeat definition")
4118 {
4119 DataEvent::InstrumentStatus(status) => {
4120 assert_eq!(
4121 status.instrument_id,
4122 InstrumentId::from("BTC-USDT-SWAP.OKX")
4123 );
4124 }
4125 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4126 }
4127 assert!(
4128 receiver.try_recv().is_err(),
4129 "unchanged definition must not be republished"
4130 );
4131 }
4132
4133 #[rstest]
4134 fn ws_instruments_changed_definition_is_republished() {
4135 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4136 let instruments_by_symbol = Arc::new(AtomicMap::new());
4137 let http = offline_http_client();
4138
4139 handle_instruments_message(
4140 &sender.clone().into(),
4141 &instruments_by_symbol,
4142 &http,
4143 &OKXDataClientConfig::default(),
4144 None,
4145 None,
4146 ws_instruments_message(swap_definition("0.1")),
4147 );
4148 handle_instruments_message(
4149 &sender.into(),
4150 &instruments_by_symbol,
4151 &http,
4152 &OKXDataClientConfig::default(),
4153 None,
4154 None,
4155 ws_instruments_message(swap_definition("0.5")),
4156 );
4157
4158 assert!(matches!(receiver.try_recv(), Ok(DataEvent::Instrument(_))));
4159 assert!(matches!(
4160 receiver.try_recv(),
4161 Ok(DataEvent::InstrumentStatus(_))
4162 ));
4163
4164 match receiver.try_recv().expect("republished instrument") {
4165 DataEvent::Instrument(instrument) => {
4166 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT-SWAP.OKX"));
4167 assert_eq!(instrument.price_increment(), Price::from("0.5"));
4168 }
4169 other => panic!("Expected DataEvent::Instrument, was {other:?}"),
4170 }
4171
4172 match receiver
4173 .try_recv()
4174 .expect("status for republished instrument")
4175 {
4176 DataEvent::InstrumentStatus(status) => {
4177 assert_eq!(
4178 status.instrument_id,
4179 InstrumentId::from("BTC-USDT-SWAP.OKX")
4180 );
4181 }
4182 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4183 }
4184 assert!(receiver.try_recv().is_err());
4185
4186 let cached = instruments_by_symbol
4187 .get_cloned(&Ustr::from("BTC-USDT-SWAP"))
4188 .expect("instrument cached in the shared cache");
4189 assert_eq!(cached.price_increment(), Price::from("0.5"));
4190 }
4191
4192 #[rstest]
4193 fn ws_instruments_invalid_definition_emits_status_only() {
4194 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4195 let instruments_by_symbol = Arc::new(AtomicMap::new());
4196 let http = offline_http_client();
4197 let mut definition = swap_definition("0.1");
4198 definition["uly"] = json!("");
4199
4200 handle_instruments_message(
4201 &sender.into(),
4202 &instruments_by_symbol,
4203 &http,
4204 &OKXDataClientConfig::default(),
4205 None,
4206 None,
4207 ws_instruments_message(definition),
4208 );
4209
4210 match receiver
4211 .try_recv()
4212 .expect("status event for invalid definition")
4213 {
4214 DataEvent::InstrumentStatus(status) => {
4215 assert_eq!(
4216 status.instrument_id,
4217 InstrumentId::from("BTC-USDT-SWAP.OKX")
4218 );
4219 }
4220 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4221 }
4222 assert!(
4223 receiver.try_recv().is_err(),
4224 "invalid definition must not publish an instrument event"
4225 );
4226 assert!(instruments_by_symbol.load().is_empty());
4227 }
4228
4229 #[rstest]
4230 fn ws_instruments_batch_publishes_each_valid_definition() {
4231 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4232 let instruments_by_symbol = Arc::new(AtomicMap::new());
4233 let http = offline_http_client();
4234 let mut eth_definition = swap_definition("0.01");
4235 eth_definition["instId"] = json!("ETH-USDT-SWAP");
4236 eth_definition["instFamily"] = json!("ETH-USDT");
4237 eth_definition["uly"] = json!("ETH-USDT");
4238 let batch = OKXWsMessage::Instruments(vec![
4239 serde_json::from_value(swap_definition("0.1")).expect("valid OKXInstrument"),
4240 serde_json::from_value(eth_definition).expect("valid OKXInstrument"),
4241 ]);
4242
4243 handle_instruments_message(
4244 &sender.into(),
4245 &instruments_by_symbol,
4246 &http,
4247 &OKXDataClientConfig::default(),
4248 None,
4249 None,
4250 batch,
4251 );
4252
4253 let mut published = Vec::new();
4254 let mut statuses = Vec::new();
4255
4256 while let Ok(event) = receiver.try_recv() {
4257 match event {
4258 DataEvent::Instrument(instrument) => published.push(instrument.id()),
4259 DataEvent::InstrumentStatus(status) => statuses.push(status.instrument_id),
4260 other => panic!("Unexpected event {other:?}"),
4261 }
4262 }
4263
4264 assert_eq!(
4265 published,
4266 vec![
4267 InstrumentId::from("BTC-USDT-SWAP.OKX"),
4268 InstrumentId::from("ETH-USDT-SWAP.OKX")
4269 ],
4270 "every valid batch item must publish its definition"
4271 );
4272 assert_eq!(
4273 statuses,
4274 vec![
4275 InstrumentId::from("BTC-USDT-SWAP.OKX"),
4276 InstrumentId::from("ETH-USDT-SWAP.OKX")
4277 ],
4278 "every batch item must keep its status event"
4279 );
4280 assert_eq!(instruments_by_symbol.load().len(), 2);
4281 }
4282
4283 #[rstest]
4284 fn ws_instruments_respects_contract_type_filter() {
4285 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4286 let instruments_by_symbol = Arc::new(AtomicMap::new());
4287 let http = offline_http_client();
4288 let config = OKXDataClientConfig::builder()
4289 .contract_types(vec![OKXContractType::Inverse])
4290 .build();
4291
4292 handle_instruments_message(
4293 &sender.clone().into(),
4294 &instruments_by_symbol,
4295 &http,
4296 &config,
4297 None,
4298 None,
4299 ws_instruments_message(swap_definition("0.1")),
4300 );
4301
4302 match receiver.try_recv().expect("status event") {
4303 DataEvent::InstrumentStatus(status) => {
4304 assert_eq!(
4305 status.instrument_id,
4306 InstrumentId::from("BTC-USDT-SWAP.OKX")
4307 );
4308 }
4309 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4310 }
4311 assert!(
4312 receiver.try_recv().is_err(),
4313 "a definition excluded by the contract type filter must not publish"
4314 );
4315 assert!(
4316 instruments_by_symbol.load().is_empty(),
4317 "a filtered definition must not enter the instrument cache"
4318 );
4319
4320 let inverse_item = test_payload("http_get_instruments_swap.json")["data"][0].clone();
4321 assert_eq!(inverse_item["instId"], json!("BTC-USD-SWAP"));
4322 assert_eq!(inverse_item["ctType"], json!("inverse"));
4323 handle_instruments_message(
4324 &sender.into(),
4325 &instruments_by_symbol,
4326 &http,
4327 &config,
4328 None,
4329 None,
4330 ws_instruments_message(inverse_item),
4331 );
4332
4333 match receiver.try_recv().expect("instrument event") {
4334 DataEvent::Instrument(instrument) => {
4335 assert_eq!(instrument.id(), InstrumentId::from("BTC-USD-SWAP.OKX"));
4336 }
4337 other => panic!("Expected DataEvent::Instrument, was {other:?}"),
4338 }
4339 assert!(matches!(
4340 receiver.try_recv(),
4341 Ok(DataEvent::InstrumentStatus(_))
4342 ));
4343 assert_eq!(instruments_by_symbol.load().len(), 1);
4344 }
4345
4346 #[rstest]
4347 fn ws_instruments_respects_family_filter() {
4348 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4349 let instruments_by_symbol = Arc::new(AtomicMap::new());
4350 let http = offline_http_client();
4351 let config = OKXDataClientConfig::builder()
4352 .instrument_types(vec![OKXInstrumentType::Swap])
4353 .instrument_families(vec!["BTC-USDT".to_string()])
4354 .build();
4355 let mut eth_definition = swap_definition("0.01");
4356 eth_definition["instId"] = json!("ETH-USDT-SWAP");
4357 eth_definition["instFamily"] = json!("ETH-USDT");
4358 eth_definition["uly"] = json!("ETH-USDT");
4359
4360 handle_instruments_message(
4361 &sender.clone().into(),
4362 &instruments_by_symbol,
4363 &http,
4364 &config,
4365 None,
4366 None,
4367 ws_instruments_message(eth_definition),
4368 );
4369
4370 match receiver.try_recv().expect("status event") {
4371 DataEvent::InstrumentStatus(status) => {
4372 assert_eq!(
4373 status.instrument_id,
4374 InstrumentId::from("ETH-USDT-SWAP.OKX")
4375 );
4376 }
4377 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4378 }
4379 assert!(
4380 receiver.try_recv().is_err(),
4381 "a definition outside the configured families must not publish"
4382 );
4383 assert!(instruments_by_symbol.load().is_empty());
4384
4385 handle_instruments_message(
4386 &sender.into(),
4387 &instruments_by_symbol,
4388 &http,
4389 &config,
4390 None,
4391 None,
4392 ws_instruments_message(swap_definition("0.1")),
4393 );
4394
4395 match receiver.try_recv().expect("instrument event") {
4396 DataEvent::Instrument(instrument) => {
4397 assert_eq!(instrument.id(), InstrumentId::from("BTC-USDT-SWAP.OKX"));
4398 }
4399 other => panic!("Expected DataEvent::Instrument, was {other:?}"),
4400 }
4401 assert!(matches!(
4402 receiver.try_recv(),
4403 Ok(DataEvent::InstrumentStatus(_))
4404 ));
4405 assert_eq!(instruments_by_symbol.load().len(), 1);
4406 }
4407
4408 #[tokio::test]
4409 async fn reconcile_fetches_duplicate_configured_families_once() {
4410 let state = RefreshServerState {
4411 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
4412 "http_get_instruments_swap.json",
4413 ))),
4414 ..RefreshServerState::default()
4415 };
4416 let addr = start_refresh_server(state.clone()).await;
4417 let http = refresh_http_client(addr);
4418 let config = OKXDataClientConfig::builder()
4419 .instrument_types(vec![OKXInstrumentType::Swap])
4420 .instrument_families(vec!["BTC-USD".to_string(), "BTC-USD".to_string()])
4421 .build();
4422 let instruments_by_symbol = Arc::new(AtomicMap::new());
4423 let update_lock = InstrumentUpdateLock::default();
4424 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4425
4426 let summary = reconcile_instruments(
4427 &http,
4428 &config,
4429 &instruments_by_symbol,
4430 &update_lock,
4431 None,
4432 None,
4433 &sender.clone().into(),
4434 )
4435 .await
4436 .expect("reconcile");
4437
4438 let queries = state.instrument_queries.lock().await;
4439 assert_eq!(
4440 queries.len(),
4441 1,
4442 "a duplicated family must be fetched only once"
4443 );
4444 drop(queries);
4445 assert_eq!(summary.fetched, 1);
4446 assert_eq!(summary.changed, 1);
4447 assert_eq!(
4448 instrument_events(&mut receiver).len(),
4449 1,
4450 "a duplicated family must not publish its instruments twice"
4451 );
4452 }
4453
4454 #[tokio::test]
4455 async fn reconcile_fetches_duplicate_configured_types_once() {
4456 let state = RefreshServerState {
4457 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
4458 "http_get_instruments_swap.json",
4459 ))),
4460 ..RefreshServerState::default()
4461 };
4462 let addr = start_refresh_server(state.clone()).await;
4463 let http = refresh_http_client(addr);
4464 let config = OKXDataClientConfig::builder()
4465 .instrument_types(vec![OKXInstrumentType::Swap, OKXInstrumentType::Swap])
4466 .build();
4467 let instruments_by_symbol = Arc::new(AtomicMap::new());
4468 let update_lock = InstrumentUpdateLock::default();
4469 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4470
4471 let summary = reconcile_instruments(
4472 &http,
4473 &config,
4474 &instruments_by_symbol,
4475 &update_lock,
4476 None,
4477 None,
4478 &sender.clone().into(),
4479 )
4480 .await
4481 .expect("reconcile");
4482
4483 let queries = state.instrument_queries.lock().await;
4484 assert_eq!(
4485 queries.len(),
4486 1,
4487 "a duplicated type must be fetched only once"
4488 );
4489 drop(queries);
4490 assert_eq!(summary.fetched, 3);
4491 assert_eq!(summary.changed, 3);
4492 assert_eq!(
4493 instrument_events(&mut receiver).len(),
4494 3,
4495 "a duplicated type must not publish its instruments twice"
4496 );
4497 }
4498
4499 #[rstest]
4500 fn definition_in_scope_matches_events_family_on_series_id() {
4501 let okx_inst = OKXInstrument {
4502 inst_type: OKXInstrumentType::Events,
4503 inst_id: Ustr::from("BTC-ABOVE-DAILY-260224-1600-65000"),
4504 inst_id_code: Some(1_000_000_001),
4505 uly: Ustr::from(""),
4506 inst_family: Ustr::from(""),
4507 series_id: Some(Ustr::from("BTC-ABOVE-DAILY")),
4508 inst_category: Some(crate::common::enums::OKXInstrumentCategory::Crypto),
4509 init_px_lmt_pct: String::new(),
4510 float_px_lmt_pct: String::new(),
4511 max_px_lmt_pct: String::new(),
4512 base_ccy: Ustr::from(""),
4513 quote_ccy: Ustr::from("USDT"),
4514 settle_ccy: Ustr::from("USDT"),
4515 ct_val: String::new(),
4516 ct_mult: String::new(),
4517 ct_val_ccy: String::new(),
4518 opt_type: crate::common::enums::OKXOptionType::None,
4519 stk: String::new(),
4520 list_time: Some(1_769_697_132_335),
4521 exp_time: Some(1_769_700_732_335),
4522 lever: String::new(),
4523 tick_sz: "0.001".to_string(),
4524 lot_sz: "1".to_string(),
4525 min_sz: "1".to_string(),
4526 ct_type: OKXContractType::None,
4527 state: OKXInstrumentStatus::Settling,
4528 rule_type: "normal".to_string(),
4529 max_lmt_sz: "1000000".to_string(),
4530 max_mkt_sz: "1000000".to_string(),
4531 max_lmt_amt: String::new(),
4532 max_mkt_amt: String::new(),
4533 max_twap_sz: String::new(),
4534 max_iceberg_sz: String::new(),
4535 max_trigger_sz: String::new(),
4536 max_stop_sz: String::new(),
4537 rpi: None,
4538 rpi_min_level: None,
4539 rpi_min_px_band: None,
4540 trade_quote_ccy_list: Vec::new(),
4541 };
4542 let instrument = crate::common::parse::parse_event_contract_instrument(
4543 &okx_inst,
4544 None,
4545 None,
4546 None,
4547 None,
4548 UnixNanos::from(1u64),
4549 )
4550 .expect("parse events instrument");
4551 let matching = OKXDataClientConfig::builder()
4552 .instrument_types(vec![OKXInstrumentType::Events])
4553 .instrument_families(vec!["BTC-ABOVE-DAILY".to_string()])
4554 .build();
4555 let other = OKXDataClientConfig::builder()
4556 .instrument_types(vec![OKXInstrumentType::Events])
4557 .instrument_families(vec!["ETH-ABOVE-DAILY".to_string()])
4558 .build();
4559
4560 assert!(definition_in_scope(&matching, &okx_inst, &instrument));
4561 assert!(!definition_in_scope(&other, &okx_inst, &instrument));
4562 }
4563
4564 #[tokio::test]
4565 async fn ws_definition_matching_rest_fetch_is_not_republished() {
4566 let state = RefreshServerState {
4567 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
4568 "http_get_instruments_swap.json",
4569 ))),
4570 ..RefreshServerState::default()
4571 };
4572 let addr = start_refresh_server(state).await;
4573 let http = refresh_http_client(addr);
4574 let config = OKXDataClientConfig::builder()
4575 .instrument_types(vec![OKXInstrumentType::Swap])
4576 .build();
4577 let instruments_by_symbol = Arc::new(AtomicMap::new());
4578 let update_lock = InstrumentUpdateLock::default();
4579 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4580
4581 reconcile_instruments(
4582 &http,
4583 &config,
4584 &instruments_by_symbol,
4585 &update_lock,
4586 None,
4587 None,
4588 &sender.clone().into(),
4589 )
4590 .await
4591 .expect("reconcile");
4592 assert_eq!(instrument_events(&mut receiver).len(), 3);
4593
4594 let rest_item = test_payload("http_get_instruments_swap.json")["data"][2].clone();
4597 assert_eq!(rest_item["instId"], json!("BTC-USDT-SWAP"));
4598 handle_instruments_message(
4599 &sender.clone().into(),
4600 &instruments_by_symbol,
4601 &http,
4602 &OKXDataClientConfig::default(),
4603 None,
4604 None,
4605 ws_instruments_message(rest_item),
4606 );
4607
4608 match receiver.try_recv().expect("status event") {
4609 DataEvent::InstrumentStatus(status) => {
4610 assert_eq!(
4611 status.instrument_id,
4612 InstrumentId::from("BTC-USDT-SWAP.OKX")
4613 );
4614 }
4615 other => panic!("Expected DataEvent::InstrumentStatus, was {other:?}"),
4616 }
4617 assert!(
4618 receiver.try_recv().is_err(),
4619 "a definition identical to the REST fetch must not be republished"
4620 );
4621 }
4622
4623 #[rstest]
4624 fn definitions_match_ignores_event_timestamps() {
4625 let okx_instrument: OKXInstrument =
4626 serde_json::from_value(swap_definition("0.1")).expect("valid OKXInstrument");
4627 let first = parse_instrument_any(
4628 &okx_instrument,
4629 None,
4630 None,
4631 None,
4632 None,
4633 UnixNanos::from(1u64),
4634 )
4635 .expect("parse")
4636 .expect("instrument");
4637 let second = parse_instrument_any(
4638 &okx_instrument,
4639 None,
4640 None,
4641 None,
4642 None,
4643 UnixNanos::from(2u64),
4644 )
4645 .expect("parse")
4646 .expect("instrument");
4647
4648 assert!(instrument_definitions_match(&first, &second));
4649 }
4650
4651 #[rstest]
4652 fn definitions_match_detects_increment_changes() {
4653 let mut pair = currency_pair_btcusdt();
4654 let mut changed = pair.clone();
4655 changed.price_increment = Price::from("0.5");
4656 pair.ts_event = UnixNanos::from(1u64);
4657 changed.ts_event = UnixNanos::from(2u64);
4658
4659 assert!(!instrument_definitions_match(
4660 &InstrumentAny::CurrencyPair(pair),
4661 &InstrumentAny::CurrencyPair(changed),
4662 ));
4663 }
4664
4665 #[rstest]
4666 fn definitions_match_detects_info_changes() {
4667 let pair = currency_pair_btcusdt();
4668 let mut changed = pair.clone();
4669 let mut info = Params::new();
4670 info.insert("okx_rpi_min_level".to_string(), json!(5));
4671 changed.info = Some(info);
4672
4673 assert!(!instrument_definitions_match(
4674 &InstrumentAny::CurrencyPair(pair),
4675 &InstrumentAny::CurrencyPair(changed),
4676 ));
4677 }
4678
4679 #[rstest]
4680 fn definitions_match_detects_id_changes() {
4681 let pair = currency_pair_btcusdt();
4682 let mut other = pair.clone();
4683 other.id = InstrumentId::from("ETH-USDT.OKX");
4684
4685 assert!(!instrument_definitions_match(
4686 &InstrumentAny::CurrencyPair(pair),
4687 &InstrumentAny::CurrencyPair(other),
4688 ));
4689 }
4690
4691 #[derive(Clone, Default)]
4692 struct RefreshServerState {
4693 instruments_payload: Arc<tokio::sync::Mutex<Value>>,
4694 spreads_payload: Arc<tokio::sync::Mutex<Value>>,
4695 instrument_queries: Arc<tokio::sync::Mutex<Vec<HashMap<String, String>>>>,
4696 spread_queries: Arc<tokio::sync::Mutex<Vec<HashMap<String, String>>>>,
4697 fail_instruments: bool,
4698 gate_instruments: Option<Arc<tokio::sync::Semaphore>>,
4699 }
4700
4701 async fn start_refresh_server(state: RefreshServerState) -> SocketAddr {
4702 let instruments_state = state.clone();
4703 let spreads_state = state;
4704
4705 let router =
4706 Router::new()
4707 .route(
4708 "/api/v5/public/instruments",
4709 get(move |Query(params): Query<HashMap<String, String>>| {
4710 let state = instruments_state.clone();
4711 async move {
4712 state.instrument_queries.lock().await.push(params.clone());
4713
4714 if let Some(gate) = &state.gate_instruments {
4715 gate.acquire()
4716 .await
4717 .expect("instruments gate open")
4718 .forget();
4719 }
4720
4721 if state.fail_instruments {
4722 return (
4723 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
4724 Json(json!({
4725 "code": "50000",
4726 "msg": "instruments endpoint unavailable",
4727 "data": []
4728 })),
4729 );
4730 }
4731
4732 let family = params.get("instFamily").cloned();
4733 let mut payload = state.instruments_payload.lock().await.clone();
4734
4735 if let Some(family) = family
4736 && let Some(data) =
4737 payload.get_mut("data").and_then(Value::as_array_mut)
4738 {
4739 data.retain(|item| {
4740 item.get("instFamily").and_then(Value::as_str)
4741 == Some(family.as_str())
4742 });
4743 }
4744 (axum::http::StatusCode::OK, Json(payload))
4745 }
4746 }),
4747 )
4748 .route(
4749 "/api/v5/sprd/spreads",
4750 get(move |Query(params): Query<HashMap<String, String>>| {
4751 let state = spreads_state.clone();
4752 async move {
4753 state.spread_queries.lock().await.push(params);
4754 Json(state.spreads_payload.lock().await.clone())
4755 }
4756 }),
4757 )
4758 .route(
4759 "/ws/public",
4760 get(|ws: axum::extract::ws::WebSocketUpgrade| async move {
4761 ws.on_upgrade(drain_ws)
4762 }),
4763 )
4764 .route(
4765 "/ws/business",
4766 get(|ws: axum::extract::ws::WebSocketUpgrade| async move {
4767 ws.on_upgrade(drain_ws)
4768 }),
4769 );
4770
4771 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
4772 .await
4773 .expect("bind");
4774 let addr = listener.local_addr().expect("local_addr");
4775 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
4776 addr
4777 }
4778
4779 async fn drain_ws(mut socket: axum::extract::ws::WebSocket) {
4780 while socket.next().await.is_some() {}
4781 }
4782
4783 fn test_payload(filename: &str) -> Value {
4784 serde_json::from_str(&load_test_json(filename)).expect("valid json fixture")
4785 }
4786
4787 fn refresh_http_client(addr: SocketAddr) -> OKXHttpClient {
4788 OKXHttpClient::new(
4789 Some(format!("http://{addr}")),
4790 5,
4791 0,
4792 1,
4793 1,
4794 OKXEnvironment::Live,
4795 None,
4796 )
4797 .expect("http client")
4798 }
4799
4800 fn spot_refresh_state() -> RefreshServerState {
4801 RefreshServerState {
4802 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
4803 "http_get_instruments_spot.json",
4804 ))),
4805 ..RefreshServerState::default()
4806 }
4807 }
4808
4809 fn instrument_events(
4810 receiver: &mut tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
4811 ) -> Vec<InstrumentAny> {
4812 let mut events = Vec::new();
4813 while let Ok(DataEvent::Instrument(instrument)) = receiver.try_recv() {
4814 events.push(instrument);
4815 }
4816 events
4817 }
4818
4819 #[tokio::test]
4820 async fn reconcile_publishes_only_new_or_changed_and_retains_missing() {
4821 let state = spot_refresh_state();
4822 let addr = start_refresh_server(state.clone()).await;
4823 let http = refresh_http_client(addr);
4824 let config = OKXDataClientConfig::default();
4825 let instruments_by_symbol = Arc::new(AtomicMap::new());
4826 let update_lock = InstrumentUpdateLock::default();
4827 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4828
4829 let summary = reconcile_instruments(
4830 &http,
4831 &config,
4832 &instruments_by_symbol,
4833 &update_lock,
4834 None,
4835 None,
4836 &sender.clone().into(),
4837 )
4838 .await
4839 .expect("initial reconcile");
4840 assert_eq!(summary.fetched, 5);
4841 assert_eq!(summary.changed, 5);
4842 assert_eq!(summary.missing, 0);
4843 assert_eq!(instrument_events(&mut receiver).len(), 5);
4844 assert_eq!(instruments_by_symbol.load().len(), 5);
4845
4846 let summary = reconcile_instruments(
4847 &http,
4848 &config,
4849 &instruments_by_symbol,
4850 &update_lock,
4851 None,
4852 None,
4853 &sender.clone().into(),
4854 )
4855 .await
4856 .expect("unchanged reconcile");
4857 assert_eq!(summary.fetched, 5);
4858 assert_eq!(summary.changed, 0);
4859 assert_eq!(summary.missing, 0);
4860 assert!(
4861 instrument_events(&mut receiver).is_empty(),
4862 "unchanged definitions must not be republished"
4863 );
4864
4865 {
4866 let mut payload = state.instruments_payload.lock().await;
4867 payload["data"][0]["tickSz"] = json!("0.5");
4868 }
4869 let summary = reconcile_instruments(
4870 &http,
4871 &config,
4872 &instruments_by_symbol,
4873 &update_lock,
4874 None,
4875 None,
4876 &sender.clone().into(),
4877 )
4878 .await
4879 .expect("changed reconcile");
4880 assert_eq!(summary.changed, 1);
4881 let events = instrument_events(&mut receiver);
4882 assert_eq!(events.len(), 1);
4883 assert_eq!(events[0].id(), InstrumentId::from("BTC-USD.OKX"));
4884 assert_eq!(events[0].price_increment(), Price::from("0.5"));
4885
4886 {
4887 let mut payload = state.instruments_payload.lock().await;
4888 let mut new_instrument = payload["data"][0].clone();
4889 new_instrument["instId"] = json!("ETH-USDT");
4890 new_instrument["baseCcy"] = json!("ETH");
4891 new_instrument["quoteCcy"] = json!("USDT");
4892 payload["data"]
4893 .as_array_mut()
4894 .expect("data array")
4895 .push(new_instrument);
4896 }
4897 let summary = reconcile_instruments(
4898 &http,
4899 &config,
4900 &instruments_by_symbol,
4901 &update_lock,
4902 None,
4903 None,
4904 &sender.clone().into(),
4905 )
4906 .await
4907 .expect("new listing reconcile");
4908 assert_eq!(summary.fetched, 6);
4909 assert_eq!(summary.changed, 1);
4910 let events = instrument_events(&mut receiver);
4911 assert_eq!(events.len(), 1);
4912 assert_eq!(events[0].id(), InstrumentId::from("ETH-USDT.OKX"));
4913 assert_eq!(instruments_by_symbol.load().len(), 6);
4914
4915 {
4916 let mut payload = state.instruments_payload.lock().await;
4917 payload["data"]
4918 .as_array_mut()
4919 .expect("data array")
4920 .remove(0);
4921 }
4922 let summary = reconcile_instruments(
4923 &http,
4924 &config,
4925 &instruments_by_symbol,
4926 &update_lock,
4927 None,
4928 None,
4929 &sender.clone().into(),
4930 )
4931 .await
4932 .expect("removal reconcile");
4933 assert_eq!(summary.fetched, 5);
4934 assert_eq!(summary.changed, 0);
4935 assert_eq!(summary.missing, 1);
4936 assert!(
4937 instrument_events(&mut receiver).is_empty(),
4938 "removed instruments must not publish events"
4939 );
4940 assert!(
4941 instruments_by_symbol
4942 .get_cloned(&Ustr::from("BTC-USD"))
4943 .is_some(),
4944 "removed instruments are retained in the cache"
4945 );
4946 }
4947
4948 #[tokio::test]
4949 async fn reconcile_surfaces_fetch_errors_without_publishing() {
4950 let state = RefreshServerState {
4951 fail_instruments: true,
4952 ..spot_refresh_state()
4953 };
4954 let addr = start_refresh_server(state).await;
4955 let http = refresh_http_client(addr);
4956 let config = OKXDataClientConfig::default();
4957 let instruments_by_symbol = Arc::new(AtomicMap::new());
4958 let update_lock = InstrumentUpdateLock::default();
4959 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4960
4961 let result = reconcile_instruments(
4962 &http,
4963 &config,
4964 &instruments_by_symbol,
4965 &update_lock,
4966 None,
4967 None,
4968 &sender.clone().into(),
4969 )
4970 .await;
4971
4972 assert!(result.is_err(), "fetch failure must surface as an error");
4973 assert!(instruments_by_symbol.load().is_empty());
4974 assert!(
4975 receiver.try_recv().is_err(),
4976 "a failed reconcile must not publish events"
4977 );
4978 }
4979
4980 #[tokio::test]
4981 async fn reconcile_requests_each_configured_family() {
4982 let state = RefreshServerState {
4983 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
4984 "http_get_instruments_swap.json",
4985 ))),
4986 ..RefreshServerState::default()
4987 };
4988 let addr = start_refresh_server(state.clone()).await;
4989 let http = refresh_http_client(addr);
4990 let config = OKXDataClientConfig::builder()
4991 .instrument_types(vec![OKXInstrumentType::Swap])
4992 .instrument_families(vec!["BTC-USD".to_string(), "BTC-USDT".to_string()])
4993 .build();
4994 let instruments_by_symbol = Arc::new(AtomicMap::new());
4995 let update_lock = InstrumentUpdateLock::default();
4996 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
4997
4998 let summary = reconcile_instruments(
4999 &http,
5000 &config,
5001 &instruments_by_symbol,
5002 &update_lock,
5003 None,
5004 None,
5005 &sender.clone().into(),
5006 )
5007 .await
5008 .expect("reconcile");
5009
5010 let queries = state.instrument_queries.lock().await;
5011 let families: Vec<Option<String>> = queries
5012 .iter()
5013 .map(|query| query.get("instFamily").cloned())
5014 .collect();
5015 assert_eq!(queries.len(), 2);
5016 assert!(families.contains(&Some("BTC-USD".to_string())));
5017 assert!(families.contains(&Some("BTC-USDT".to_string())));
5018 drop(queries);
5019
5020 assert_eq!(summary.fetched, 2);
5021 assert_eq!(summary.changed, 2);
5022 let ids: Vec<InstrumentId> = instrument_events(&mut receiver)
5023 .iter()
5024 .map(Instrument::id)
5025 .collect();
5026 assert!(ids.contains(&InstrumentId::from("BTC-USD-SWAP.OKX")));
5027 assert!(ids.contains(&InstrumentId::from("BTC-USDT-SWAP.OKX")));
5028 }
5029
5030 #[rstest]
5031 #[case::inverse_keeps_inverse_only(vec![OKXContractType::Inverse], 1, "BTC-USD-SWAP.OKX")]
5032 #[case::linear_keeps_linear_only(vec![OKXContractType::Linear], 2, "BTC-USDT-SWAP.OKX")]
5033 #[tokio::test]
5034 async fn reconcile_applies_contract_type_filter(
5035 #[case] filter: Vec<OKXContractType>,
5036 #[case] expected_count: usize,
5037 #[case] expected_id: &str,
5038 ) {
5039 let state = RefreshServerState {
5040 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
5041 "http_get_instruments_swap.json",
5042 ))),
5043 ..RefreshServerState::default()
5044 };
5045 let addr = start_refresh_server(state).await;
5046 let http = refresh_http_client(addr);
5047 let config = OKXDataClientConfig::builder()
5048 .instrument_types(vec![OKXInstrumentType::Swap])
5049 .contract_types(filter)
5050 .build();
5051 let instruments_by_symbol = Arc::new(AtomicMap::new());
5052 let update_lock = InstrumentUpdateLock::default();
5053 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
5054
5055 let summary = reconcile_instruments(
5056 &http,
5057 &config,
5058 &instruments_by_symbol,
5059 &update_lock,
5060 None,
5061 None,
5062 &sender.clone().into(),
5063 )
5064 .await
5065 .expect("reconcile");
5066
5067 assert_eq!(summary.fetched, expected_count);
5068 assert_eq!(summary.changed, expected_count);
5069 let events = instrument_events(&mut receiver);
5070 assert_eq!(events.len(), expected_count);
5071 assert!(
5072 events
5073 .iter()
5074 .any(|i| i.id() == InstrumentId::from(expected_id)),
5075 "expected {expected_id} in filtered results"
5076 );
5077 }
5078
5079 #[tokio::test]
5080 async fn reconcile_includes_spreads_when_load_spreads_enabled() {
5081 let state = RefreshServerState {
5082 instruments_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
5083 "http_get_instruments_spot.json",
5084 ))),
5085 spreads_payload: Arc::new(tokio::sync::Mutex::new(test_payload(
5086 "http_get_spreads.json",
5087 ))),
5088 ..RefreshServerState::default()
5089 };
5090 let addr = start_refresh_server(state.clone()).await;
5091 let http = refresh_http_client(addr);
5092 let config = OKXDataClientConfig::builder().load_spreads(true).build();
5093 let instruments_by_symbol = Arc::new(AtomicMap::new());
5094 let update_lock = InstrumentUpdateLock::default();
5095 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
5096
5097 let summary = reconcile_instruments(
5098 &http,
5099 &config,
5100 &instruments_by_symbol,
5101 &update_lock,
5102 None,
5103 None,
5104 &sender.clone().into(),
5105 )
5106 .await
5107 .expect("reconcile");
5108
5109 assert_eq!(state.spread_queries.lock().await.len(), 1);
5110 assert_eq!(summary.fetched, 7);
5111 assert_eq!(summary.changed, 7);
5112 let ids: Vec<InstrumentId> = instrument_events(&mut receiver)
5113 .iter()
5114 .map(Instrument::id)
5115 .collect();
5116 assert!(ids.contains(&InstrumentId::from("ETH-USD-SWAP_ETH-USD-231229.OKX")));
5117 assert!(ids.contains(&InstrumentId::from("BTC-USDT_BTC-USDT-SWAP.OKX")));
5118 }
5119
5120 #[tokio::test]
5121 async fn reconcile_updates_all_caches_before_publishing() {
5122 let state = spot_refresh_state();
5123 let addr = start_refresh_server(state).await;
5124 let http = refresh_http_client(addr);
5125 let config = OKXDataClientConfig::default();
5126 let instruments_by_symbol = Arc::new(AtomicMap::new());
5127 let update_lock = Arc::new(InstrumentUpdateLock::default());
5128 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
5129 let ws = offline_ws_client();
5130 let ws_business = offline_ws_client();
5131
5132 let instruments_task = instruments_by_symbol.clone();
5133 let update_lock_task = update_lock.clone();
5134 let http_task = http.clone();
5135 let ws_task = ws.clone();
5136 let ws_business_task = ws_business.clone();
5137
5138 let reconcile = tokio::spawn(async move {
5139 reconcile_instruments(
5140 &http_task,
5141 &config,
5142 &instruments_task,
5143 &update_lock_task,
5144 Some(&ws_task),
5145 Some(&ws_business_task),
5146 &sender.clone().into(),
5147 )
5148 .await
5149 });
5150
5151 let event = receiver.recv().await.expect("instrument event");
5152 let DataEvent::Instrument(instrument) = event else {
5153 panic!("Expected DataEvent::Instrument, was {event:?}");
5154 };
5155
5156 assert!(
5157 instruments_by_symbol
5158 .load()
5159 .contains_key(&instrument.symbol().inner()),
5160 "data client cache must be updated before publishing"
5161 );
5162 assert!(
5163 http.get_instrument(&instrument.symbol().inner()).is_some(),
5164 "HTTP client cache must be updated before publishing"
5165 );
5166 assert!(
5167 ws.instruments_snapshot()
5168 .contains_key(&instrument.symbol().inner()),
5169 "public WebSocket cache must be updated before publishing"
5170 );
5171 assert!(
5172 ws_business
5173 .instruments_snapshot()
5174 .contains_key(&instrument.symbol().inner()),
5175 "business WebSocket cache must be updated before publishing"
5176 );
5177 reconcile.await.expect("reconcile task").expect("reconcile");
5178 }
5179
5180 #[tokio::test]
5181 async fn reconcile_skips_publish_when_cache_changes_during_fetch() {
5182 let gate = Arc::new(tokio::sync::Semaphore::new(0));
5183 let state = RefreshServerState {
5184 gate_instruments: Some(gate.clone()),
5185 ..spot_refresh_state()
5186 };
5187 let addr = start_refresh_server(state.clone()).await;
5188 let http = refresh_http_client(addr);
5189 let config = OKXDataClientConfig::default();
5190 let instruments_by_symbol = Arc::new(AtomicMap::new());
5191 let update_lock = Arc::new(InstrumentUpdateLock::default());
5192 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
5193
5194 gate.add_permits(1);
5195 let summary = reconcile_instruments(
5196 &http,
5197 &config,
5198 &instruments_by_symbol,
5199 &update_lock,
5200 None,
5201 None,
5202 &sender.clone().into(),
5203 )
5204 .await
5205 .expect("seed reconcile");
5206 assert_eq!(summary.changed, 5);
5207 assert_eq!(instrument_events(&mut receiver).len(), 5);
5208
5209 let reconcile = {
5210 let http = http.clone();
5211 let instruments_by_symbol = instruments_by_symbol.clone();
5212 let update_lock = update_lock.clone();
5213 let sender = sender.clone();
5214
5215 tokio::spawn(async move {
5216 reconcile_instruments(
5217 &http,
5218 &config,
5219 &instruments_by_symbol,
5220 &update_lock,
5221 None,
5222 None,
5223 &sender.clone().into(),
5224 )
5225 .await
5226 })
5227 };
5228
5229 let deadline = Instant::now() + Duration::from_secs(5);
5230 while state.instrument_queries.lock().await.len() < 2 {
5231 assert!(Instant::now() < deadline, "refresh fetch not in flight");
5232 tokio::time::sleep(Duration::from_millis(10)).await;
5233 }
5234
5235 let mut v2_item = test_payload("http_get_instruments_spot.json")["data"][0].clone();
5237 v2_item["tickSz"] = json!("0.5");
5238 let v2: OKXInstrument = serde_json::from_value(v2_item).expect("valid OKXInstrument");
5239 let v2 = parse_instrument_any(&v2, None, None, None, None, UnixNanos::from(1u64))
5240 .expect("parse")
5241 .expect("instrument");
5242 {
5243 let _guard = update_lock.mutex.lock();
5244 publish_instrument_updates(
5245 std::slice::from_ref(&v2),
5246 &instruments_by_symbol,
5247 &http,
5248 None,
5249 None,
5250 &update_lock,
5251 &sender.clone().into(),
5252 );
5253 }
5254
5255 match receiver.try_recv().expect("concurrent update event") {
5256 DataEvent::Instrument(instrument) => {
5257 assert_eq!(instrument.price_increment(), Price::from("0.5"));
5258 }
5259 other => panic!("Expected DataEvent::Instrument, was {other:?}"),
5260 }
5261
5262 gate.add_permits(1);
5263 let summary = reconcile.await.expect("reconcile task").expect("reconcile");
5264 assert_eq!(
5265 summary.changed, 0,
5266 "a pass whose snapshot went stale mid-fetch must skip publishing"
5267 );
5268 assert!(
5269 instrument_events(&mut receiver).is_empty(),
5270 "the stale pass must not republish the older definition"
5271 );
5272 let cached = instruments_by_symbol
5273 .get_cloned(&Ustr::from("BTC-USD"))
5274 .expect("instrument cached");
5275 assert_eq!(
5276 cached.price_increment(),
5277 Price::from("0.5"),
5278 "the instrument cache keeps the fresher concurrent definition"
5279 );
5280 assert_eq!(
5281 http.get_instrument(&Ustr::from("BTC-USD"))
5282 .map(|instrument| instrument.price_increment()),
5283 Some(Price::from("0.5")),
5284 "the HTTP cache keeps the fresher concurrent definition"
5285 );
5286 }
5287
5288 #[tokio::test]
5289 async fn spawn_instrument_refresh_skipped_when_interval_zero() {
5290 let config = OKXDataClientConfig::builder()
5291 .update_instruments_interval_mins(0)
5292 .build();
5293 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5294 replace_data_event_sender(sender);
5295 let client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5296
5297 client.register_instrument_refresh().unwrap();
5298 assert!(client.tasks.is_empty());
5299 }
5300
5301 #[rstest]
5302 #[case::reset(DataTaskBoundary::Reset)]
5303 #[case::dispose(DataTaskBoundary::Dispose)]
5304 #[case::repeated_stop(DataTaskBoundary::RepeatedStop)]
5305 #[tokio::test]
5306 async fn lifecycle_boundary_terminates_owned_data_task(#[case] boundary: DataTaskBoundary) {
5307 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5308 replace_data_event_sender(sender);
5309 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, OKXDataClientConfig::default())
5310 .expect("data client");
5311
5312 if matches!(boundary, DataTaskBoundary::RepeatedStop) {
5313 client.stop().expect("initial stop");
5314 }
5315
5316 let (drop_tx, drop_rx) = tokio::sync::oneshot::channel();
5317 let signal = DropSignal(Some(drop_tx));
5318 client.spawn_ws(
5319 async move {
5320 let _signal = signal;
5321 std::future::pending::<anyhow::Result<()>>().await
5322 },
5323 "pending lifecycle task",
5324 );
5325
5326 match boundary {
5327 DataTaskBoundary::Reset => client.reset().expect("reset"),
5328 DataTaskBoundary::Dispose => client.dispose().expect("dispose"),
5329 DataTaskBoundary::RepeatedStop => client.stop().expect("repeated stop"),
5330 }
5331
5332 tokio::time::timeout(Duration::from_secs(1), drop_rx)
5333 .await
5334 .expect("lifecycle boundary must drop the owned task")
5335 .expect("drop signal");
5336 terminate_tasks(&client.tasks, "test data client")
5337 .await
5338 .expect("data task terminated");
5339 assert!(client.tasks.is_empty());
5340 }
5341
5342 #[tokio::test]
5343 async fn reset_prevents_in_flight_request_from_publishing() {
5344 let gate = Arc::new(tokio::sync::Semaphore::new(0));
5345 let state = RefreshServerState {
5346 gate_instruments: Some(Arc::clone(&gate)),
5347 ..spot_refresh_state()
5348 };
5349 let addr = start_refresh_server(state.clone()).await;
5350 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
5351 replace_data_event_sender(sender);
5352 let config = OKXDataClientConfig {
5353 instrument_types: vec![OKXInstrumentType::Spot],
5354 base_url_http: Some(format!("http://{addr}")),
5355 http_timeout_secs: 5,
5356 max_retries: 0,
5357 retry_delay_initial_ms: 1,
5358 retry_delay_max_ms: 1,
5359 ..OKXDataClientConfig::default()
5360 };
5361 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5362 let request = RequestInstruments::new(
5363 None,
5364 None,
5365 Some(*OKX_CLIENT_ID),
5366 None,
5367 UUID4::new(),
5368 UnixNanos::default(),
5369 None,
5370 );
5371
5372 client
5373 .request_instruments(request)
5374 .expect("request instruments");
5375 wait_until_async(
5376 || {
5377 let state = state.clone();
5378 async move { !state.instrument_queries.lock().await.is_empty() }
5379 },
5380 Duration::from_secs(1),
5381 )
5382 .await;
5383
5384 client.reset().expect("reset");
5385 wait_until_async(
5386 || async { client.tasks.all_finished() },
5387 Duration::from_secs(1),
5388 )
5389 .await;
5390 gate.add_permits(1);
5391
5392 assert!(client.instruments_by_symbol.load().is_empty());
5393 assert!(matches!(
5394 receiver.try_recv(),
5395 Err(tokio::sync::mpsc::error::TryRecvError::Empty)
5396 ));
5397 terminate_tasks(&client.tasks, "test data client")
5398 .await
5399 .expect("data task terminated");
5400 }
5401
5402 #[tokio::test]
5403 async fn spawn_instrument_refresh_registers_task() {
5404 let config = OKXDataClientConfig::builder()
5405 .update_instruments_interval_mins(60)
5406 .build();
5407 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5408 replace_data_event_sender(sender);
5409 let client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5410
5411 client.register_instrument_refresh().unwrap();
5412 assert_eq!(client.tasks.len(), 1);
5413
5414 terminate_tasks(&client.tasks, "test data client")
5415 .await
5416 .expect("refresh task joins after cancel");
5417 }
5418
5419 #[tokio::test]
5420 async fn reconnect_does_not_leak_refresh_tasks() {
5421 let state = spot_refresh_state();
5422 let addr = start_refresh_server(state).await;
5423 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5424 replace_data_event_sender(sender);
5425 let config = OKXDataClientConfig {
5426 instrument_types: vec![OKXInstrumentType::Spot],
5427 base_url_http: Some(format!("http://{addr}")),
5428 base_url_ws_public: Some(format!("ws://{addr}/ws/public")),
5429 base_url_ws_business: Some(format!("ws://{addr}/ws/business")),
5430 environment: OKXEnvironment::Live,
5431 http_timeout_secs: 5,
5432 max_retries: 0,
5433 retry_delay_initial_ms: 1,
5434 retry_delay_max_ms: 1,
5435 book_stale_check_interval_secs: 0,
5436 update_instruments_interval_mins: 60,
5437 ..OKXDataClientConfig::default()
5438 };
5439 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5440
5441 for cycle in 1..=2 {
5442 client.connect().await.expect("connect");
5443 assert_eq!(
5444 client.tasks.len(),
5445 3,
5446 "cycle {cycle}: two stream tasks and one refresh task"
5447 );
5448 client.disconnect().await.expect("disconnect");
5449 assert!(
5450 client.tasks.is_empty(),
5451 "cycle {cycle}: teardown must join every task"
5452 );
5453 }
5454
5455 client.connect().await.expect("connect");
5456 client.connect().await.expect("repeated connect is a no-op");
5457 assert_eq!(client.tasks.len(), 3);
5458 client.disconnect().await.expect("disconnect");
5459 }
5460
5461 #[tokio::test]
5462 async fn reset_drains_old_generation_before_reconnect() {
5463 let state = spot_refresh_state();
5464 let addr = start_refresh_server(state).await;
5465 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5466 replace_data_event_sender(sender);
5467 let config = OKXDataClientConfig {
5468 instrument_types: vec![OKXInstrumentType::Spot],
5469 base_url_http: Some(format!("http://{addr}")),
5470 base_url_ws_public: Some(format!("ws://{addr}/ws/public")),
5471 base_url_ws_business: Some(format!("ws://{addr}/ws/business")),
5472 environment: OKXEnvironment::Live,
5473 http_timeout_secs: 5,
5474 max_retries: 0,
5475 retry_delay_initial_ms: 1,
5476 retry_delay_max_ms: 1,
5477 book_stale_check_interval_secs: 0,
5478 update_instruments_interval_mins: 60,
5479 ..OKXDataClientConfig::default()
5480 };
5481 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5482
5483 client.connect().await.expect("initial connect");
5484 client.reset().expect("reset");
5485 client.connect().await.expect("reconnect after reset");
5486
5487 assert_eq!(client.tasks.len(), 3);
5488 assert!(!client.tasks.all_finished());
5489 client.disconnect().await.expect("disconnect");
5490 }
5491
5492 #[tokio::test]
5493 async fn reconnect_does_not_republish_unchanged_instruments() {
5494 let state = spot_refresh_state();
5495 let addr = start_refresh_server(state.clone()).await;
5496 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
5497 replace_data_event_sender(sender);
5498 let config = OKXDataClientConfig {
5499 instrument_types: vec![OKXInstrumentType::Spot],
5500 base_url_http: Some(format!("http://{addr}")),
5501 base_url_ws_public: Some(format!("ws://{addr}/ws/public")),
5502 base_url_ws_business: Some(format!("ws://{addr}/ws/business")),
5503 environment: OKXEnvironment::Live,
5504 http_timeout_secs: 5,
5505 max_retries: 0,
5506 retry_delay_initial_ms: 1,
5507 retry_delay_max_ms: 1,
5508 book_stale_check_interval_secs: 0,
5509 update_instruments_interval_mins: 60,
5510 ..OKXDataClientConfig::default()
5511 };
5512 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5513
5514 client.connect().await.expect("first connect");
5515 assert_eq!(
5516 instrument_events(&mut receiver).len(),
5517 5,
5518 "first connect publishes the full cache"
5519 );
5520 client.disconnect().await.expect("disconnect");
5521
5522 client.connect().await.expect("reconnect");
5523 assert!(
5524 instrument_events(&mut receiver).is_empty(),
5525 "reconnect must not republish unchanged instruments"
5526 );
5527 client.disconnect().await.expect("disconnect");
5528
5529 {
5530 let mut payload = state.instruments_payload.lock().await;
5531 payload["data"][0]["tickSz"] = json!("0.5");
5532 }
5533 client.connect().await.expect("third connect");
5534 let events = instrument_events(&mut receiver);
5535 assert_eq!(
5536 events.len(),
5537 1,
5538 "reconnect publishes only changed definitions"
5539 );
5540 assert_eq!(events[0].id(), InstrumentId::from("BTC-USD.OKX"));
5541 client.disconnect().await.expect("disconnect");
5542 }
5543
5544 #[tokio::test]
5545 async fn stop_cancels_refresh_task() {
5546 let state = spot_refresh_state();
5547 let addr = start_refresh_server(state).await;
5548 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5549 replace_data_event_sender(sender);
5550 let config = OKXDataClientConfig {
5551 instrument_types: vec![OKXInstrumentType::Spot],
5552 base_url_http: Some(format!("http://{addr}")),
5553 base_url_ws_public: Some(format!("ws://{addr}/ws/public")),
5554 base_url_ws_business: Some(format!("ws://{addr}/ws/business")),
5555 environment: OKXEnvironment::Live,
5556 http_timeout_secs: 5,
5557 max_retries: 0,
5558 retry_delay_initial_ms: 1,
5559 retry_delay_max_ms: 1,
5560 book_stale_check_interval_secs: 0,
5561 update_instruments_interval_mins: 60,
5562 ..OKXDataClientConfig::default()
5563 };
5564 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5565
5566 client.connect().await.expect("connect");
5567 assert_eq!(client.tasks.len(), 3);
5568
5569 client.stop().expect("stop");
5570 terminate_tasks(&client.tasks, "test data client")
5571 .await
5572 .expect("stop must cancel every spawned task");
5573
5574 client.disconnect().await.expect("disconnect");
5575 }
5576
5577 #[tokio::test]
5578 async fn zero_interval_disables_refresh_on_connect() {
5579 let addr = start_refresh_server(spot_refresh_state()).await;
5580 let (sender, _receiver) = tokio::sync::mpsc::unbounded_channel();
5581 replace_data_event_sender(sender);
5582 let config = OKXDataClientConfig {
5583 instrument_types: vec![OKXInstrumentType::Spot],
5584 base_url_http: Some(format!("http://{addr}")),
5585 base_url_ws_public: Some(format!("ws://{addr}/ws/public")),
5586 base_url_ws_business: Some(format!("ws://{addr}/ws/business")),
5587 environment: OKXEnvironment::Live,
5588 http_timeout_secs: 5,
5589 max_retries: 0,
5590 retry_delay_initial_ms: 1,
5591 retry_delay_max_ms: 1,
5592 book_stale_check_interval_secs: 0,
5593 update_instruments_interval_mins: 0,
5594 ..OKXDataClientConfig::default()
5595 };
5596 let mut client = OKXDataClient::new(*OKX_CLIENT_ID, config).expect("data client");
5597
5598 client.connect().await.expect("connect");
5599 assert_eq!(
5600 client.tasks.len(),
5601 2,
5602 "only the two stream tasks run when refresh is disabled"
5603 );
5604
5605 client.disconnect().await.expect("disconnect");
5606 }
5607}
5608
5609#[cfg(test)]
5610#[path = "../tests/integration/book_sync.rs"]
5611mod book_sync_tests;