Skip to main content

nautilus_okx/
data.rs

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