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