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