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