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