Skip to main content

nautilus_derive/
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 Derive adapter.
17
18use std::{
19    num::NonZeroUsize,
20    str::FromStr,
21    sync::{
22        Arc, Mutex,
23        atomic::{AtomicBool, Ordering},
24    },
25};
26
27use ahash::{AHashMap, AHashSet};
28use anyhow::Context;
29use async_trait::async_trait;
30use dashmap::DashMap;
31use nautilus_common::{
32    cache::{InstrumentLookupError, quote::QuoteCache},
33    clients::DataClient,
34    live::{get_runtime, runner::get_data_event_sender},
35    messages::{
36        DataEvent,
37        data::{
38            BarsResponse, DataResponse, ForwardPricesResponse, FundingRatesResponse,
39            InstrumentResponse, InstrumentsResponse, QuotesResponse, RequestBars,
40            RequestForwardPrices, RequestFundingRates, RequestInstrument, RequestInstruments,
41            RequestQuotes, RequestTrades, SubscribeBookDeltas, SubscribeBookDepth10,
42            SubscribeFundingRates, SubscribeIndexPrices, SubscribeMarkPrices,
43            SubscribeOptionGreeks, SubscribeQuotes, SubscribeTrades, TradesResponse,
44            UnsubscribeBookDeltas, UnsubscribeBookDepth10, UnsubscribeFundingRates,
45            UnsubscribeIndexPrices, UnsubscribeMarkPrices, UnsubscribeOptionGreeks,
46            UnsubscribeQuotes, UnsubscribeTrades,
47        },
48    },
49    providers::InstrumentProvider,
50};
51use nautilus_core::{
52    AtomicMap, AtomicSet, MUTEX_POISONED, Params, UnixNanos,
53    datetime::{NANOSECONDS_IN_SECOND, datetime_to_unix_nanos},
54    time::{AtomicTime, get_atomic_clock_realtime},
55};
56use nautilus_model::{
57    data::{Bar, Data, ForwardPrice, OrderBookDeltas_API, QuoteTick},
58    enums::{AggregationSource, BookType, PriceType},
59    identifiers::{ClientId, InstrumentId, Venue},
60    instruments::{Instrument, InstrumentAny},
61    types::{Price, Quantity},
62};
63use tokio::task::JoinHandle;
64use tokio_util::sync::CancellationToken;
65
66use crate::{
67    common::{
68        consts::{
69            DERIVE_CANDLES_DEFAULT_LIMIT, DERIVE_CANDLES_MAX_PAGES, DERIVE_TRADES_PAGE_SIZE,
70            DERIVE_VENUE,
71        },
72        enums::{
73            DeriveInstrumentType, DeriveOrderbookDepth, DeriveOrderbookGroup, DeriveTickerInterval,
74        },
75        parse::{format_instrument_id, format_venue_symbol, parse_derive_instrument_any},
76    },
77    config::DeriveDataClientConfig,
78    http::DeriveHttpClient,
79    providers::{
80        DeriveInstrumentProvider, fetch_instrument_definitions, parse_instrument_definitions,
81    },
82    websocket::{
83        DEFAULT_ORDERBOOK_DEPTH, DEFAULT_ORDERBOOK_GROUP, DEFAULT_TICKER_INTERVAL,
84        DerivePublicWsData, DeriveTickerMsg, DeriveWebSocketClient,
85        DeriveWebSocketSubscriptionHandle, DeriveWsMessage, WsMessageContext,
86        bar_spec_to_derive_period, orderbook_channel, parse_candle_record, parse_funding_rate,
87        parse_funding_rate_history_record, parse_index_price, parse_mark_price,
88        parse_option_greeks, parse_orderbook_deltas, parse_orderbook_depth10, parse_public_ws_data,
89        parse_ticker_quote, parse_ticker_quote_from_rest, parse_trade_tick, ticker_channel,
90        trades_channel,
91    },
92};
93
94/// Derive live data client.
95#[derive(Debug)]
96pub struct DeriveDataClient {
97    client_id: ClientId,
98    config: DeriveDataClientConfig,
99    http_client: DeriveHttpClient,
100    provider: DeriveInstrumentProvider,
101    ws_client: DeriveWebSocketClient,
102    is_connected: AtomicBool,
103    cancellation_token: CancellationToken,
104    ws_stream_handle: Mutex<Option<JoinHandle<()>>>,
105    pending_tasks: Mutex<Vec<JoinHandle<()>>>,
106    data_sender: tokio::sync::mpsc::UnboundedSender<DataEvent>,
107    instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
108    active_book_delta_channels: Arc<AtomicMap<InstrumentId, String>>,
109    active_book_depth10_channels: Arc<AtomicMap<InstrumentId, String>>,
110    active_ticker_channels: Arc<AtomicMap<InstrumentId, String>>,
111    active_quote_subs: Arc<AtomicSet<InstrumentId>>,
112    active_trade_subs: Arc<AtomicSet<InstrumentId>>,
113    active_trade_channels: Arc<DashMap<String, ()>>,
114    active_mark_subs: Arc<AtomicSet<InstrumentId>>,
115    active_index_subs: Arc<AtomicSet<InstrumentId>>,
116    active_funding_subs: Arc<AtomicSet<InstrumentId>>,
117    active_greeks_subs: Arc<AtomicSet<InstrumentId>>,
118    clock: &'static AtomicTime,
119}
120
121impl DeriveDataClient {
122    /// Creates a new [`DeriveDataClient`] instance.
123    ///
124    /// # Errors
125    ///
126    /// Returns an error when the HTTP client cannot be initialized.
127    pub fn new(client_id: ClientId, config: DeriveDataClientConfig) -> anyhow::Result<Self> {
128        let clock = get_atomic_clock_realtime();
129        let data_sender = get_data_event_sender();
130        let http_client = DeriveHttpClient::new(
131            config.rest_url(),
132            Some(config.http_timeout_secs),
133            config.proxy_url.clone(),
134            None,
135        )?;
136        let provider = DeriveInstrumentProvider::with_expired(
137            http_client.clone(),
138            config.currencies.clone(),
139            config.include_expired,
140        );
141        let ws_client = DeriveWebSocketClient::new(
142            Some(config.ws_url()),
143            config.environment,
144            config.transport_backend,
145            config.proxy_url.clone(),
146        );
147
148        Ok(Self {
149            client_id,
150            config,
151            http_client,
152            provider,
153            ws_client,
154            is_connected: AtomicBool::new(false),
155            cancellation_token: CancellationToken::new(),
156            ws_stream_handle: Mutex::new(None),
157            pending_tasks: Mutex::new(Vec::new()),
158            data_sender,
159            instruments: Arc::new(AtomicMap::new()),
160            active_book_delta_channels: Arc::new(AtomicMap::new()),
161            active_book_depth10_channels: Arc::new(AtomicMap::new()),
162            active_ticker_channels: Arc::new(AtomicMap::new()),
163            active_quote_subs: Arc::new(AtomicSet::new()),
164            active_trade_subs: Arc::new(AtomicSet::new()),
165            active_trade_channels: Arc::new(DashMap::new()),
166            active_mark_subs: Arc::new(AtomicSet::new()),
167            active_index_subs: Arc::new(AtomicSet::new()),
168            active_funding_subs: Arc::new(AtomicSet::new()),
169            active_greeks_subs: Arc::new(AtomicSet::new()),
170            clock,
171        })
172    }
173
174    /// Spawns a fire-and-forget task, tracks its handle in `pending_tasks`
175    /// for teardown abort, and logs failures with the task description.
176    fn spawn_task<F>(&self, description: &'static str, fut: F)
177    where
178        F: std::future::Future<Output = anyhow::Result<()>> + Send + 'static,
179    {
180        let runtime = get_runtime();
181        let handle = runtime.spawn(async move {
182            if let Err(e) = fut.await {
183                log::warn!("{description} failed: {e:?}");
184            }
185        });
186
187        let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
188        // Prune finished handles before pushing so the Vec doesn't grow
189        // unboundedly across long-running sessions.
190        tasks.retain(|handle| !handle.is_finished());
191        tasks.push(handle);
192    }
193
194    /// Aborts every tracked pending task; used by `disconnect` and `reset`.
195    fn abort_pending_tasks(&self) {
196        let mut tasks = self.pending_tasks.lock().expect(MUTEX_POISONED);
197        for handle in tasks.drain(..) {
198            handle.abort();
199        }
200    }
201
202    /// Clears every local subscription map. Called from `disconnect` and
203    /// `reset` because the venue drops all subscriptions on socket close,
204    /// and aborted in-flight subscribe tasks can leak entries staged before
205    /// spawn (they never reach their on-error rollback branch).
206    fn clear_subscription_state(&self) {
207        self.active_book_delta_channels.store(AHashMap::new());
208        self.active_book_depth10_channels.store(AHashMap::new());
209        self.active_ticker_channels.store(AHashMap::new());
210        self.active_quote_subs.store(AHashSet::new());
211        self.active_trade_subs.store(AHashSet::new());
212        self.active_trade_channels.clear();
213        self.active_mark_subs.store(AHashSet::new());
214        self.active_index_subs.store(AHashSet::new());
215        self.active_funding_subs.store(AHashSet::new());
216        self.active_greeks_subs.store(AHashSet::new());
217    }
218
219    fn spawn_stream_task(&self, mut rx: tokio::sync::mpsc::UnboundedReceiver<DeriveWsMessage>) {
220        let mut ctx = WsMessageContext {
221            clock: self.clock,
222            data_sender: self.data_sender.clone(),
223            instruments: Arc::clone(&self.instruments),
224            active_book_delta_channels: Arc::clone(&self.active_book_delta_channels),
225            active_book_depth10_channels: Arc::clone(&self.active_book_depth10_channels),
226            active_ticker_channels: Arc::clone(&self.active_ticker_channels),
227            active_quote_subs: Arc::clone(&self.active_quote_subs),
228            active_trade_subs: Arc::clone(&self.active_trade_subs),
229            active_mark_subs: Arc::clone(&self.active_mark_subs),
230            active_index_subs: Arc::clone(&self.active_index_subs),
231            active_funding_subs: Arc::clone(&self.active_funding_subs),
232            active_greeks_subs: Arc::clone(&self.active_greeks_subs),
233            quote_cache: QuoteCache::new(),
234        };
235        let cancellation = self.cancellation_token.clone();
236
237        let handle = get_runtime().spawn(async move {
238            loop {
239                tokio::select! {
240                    maybe_msg = rx.recv() => {
241                        match maybe_msg {
242                            Some(msg) => Self::handle_ws_message(msg, &mut ctx),
243                            None => {
244                                log::debug!("Derive WebSocket data stream ended");
245                                break;
246                            }
247                        }
248                    }
249                    () = cancellation.cancelled() => {
250                        log::debug!("Derive WebSocket data stream task cancelled");
251                        break;
252                    }
253                }
254            }
255        });
256
257        let mut slot = self.ws_stream_handle.lock().expect(MUTEX_POISONED);
258        *slot = Some(handle);
259    }
260
261    fn handle_ws_message(message: DeriveWsMessage, ctx: &mut WsMessageContext) {
262        match message {
263            DeriveWsMessage::Subscription(payload) => match parse_public_ws_data(&payload) {
264                Ok(data) => Self::handle_public_ws_data(data, ctx),
265                Err(e) => {
266                    // Include channel and a truncated payload snippet so the
267                    // wire shape that broke the parser is actionable from the
268                    // log alone.
269                    let snippet = truncated_payload_snippet(payload.data.get());
270                    log::warn!(
271                        "Failed to parse Derive public WS data on channel `{}`: {e}; payload: {snippet}",
272                        payload.channel,
273                    );
274                }
275            },
276            DeriveWsMessage::Reconnected => {
277                ctx.quote_cache.clear();
278                log::info!("Derive WebSocket reconnected");
279            }
280            DeriveWsMessage::Authenticated => log::debug!("Derive WebSocket authenticated"),
281        }
282    }
283
284    fn handle_public_ws_data(data: DerivePublicWsData, ctx: &mut WsMessageContext) {
285        match data {
286            DerivePublicWsData::Orderbook(msg) => {
287                let instrument_id = msg.data.instrument_id();
288                let channel = msg.channel.as_str();
289                let deltas_active =
290                    channel_is_active(&ctx.active_book_delta_channels, instrument_id, channel);
291                let depth10_active =
292                    channel_is_active(&ctx.active_book_depth10_channels, instrument_id, channel);
293
294                if !deltas_active && !depth10_active {
295                    return;
296                }
297
298                let Some(instrument) = ctx.instruments.get_cloned(&instrument_id) else {
299                    log::warn!("Orderbook message received for unknown instrument {instrument_id}");
300                    return;
301                };
302
303                let ts_init = ctx.clock.get_time_ns();
304
305                if deltas_active {
306                    match parse_orderbook_deltas(
307                        &msg,
308                        instrument.price_precision(),
309                        instrument.size_precision(),
310                        ts_init,
311                    ) {
312                        Ok(deltas) => {
313                            Self::send_data(ctx, Data::Deltas(OrderBookDeltas_API::new(deltas)));
314                        }
315                        Err(e) => log::warn!("Failed to parse Derive orderbook deltas: {e}"),
316                    }
317                }
318
319                if depth10_active {
320                    match parse_orderbook_depth10(
321                        &msg,
322                        instrument.price_precision(),
323                        instrument.size_precision(),
324                        ts_init,
325                    ) {
326                        Ok(depth) => Self::send_data(ctx, Data::Depth10(Box::new(depth))),
327                        Err(e) => log::warn!("Failed to parse Derive orderbook depth10: {e}"),
328                    }
329                }
330            }
331            DerivePublicWsData::Trades(msg) => {
332                let ts_init = ctx.clock.get_time_ns();
333
334                for trade in &msg.trades {
335                    let instrument_id = format_instrument_id(trade.instrument_name.as_str());
336
337                    if !ctx.active_trade_subs.contains(&instrument_id) {
338                        continue;
339                    }
340
341                    let Some(instrument) = ctx.instruments.get_cloned(&instrument_id) else {
342                        log::warn!("Trade message received for unknown instrument {instrument_id}");
343                        continue;
344                    };
345
346                    match parse_trade_tick(
347                        trade,
348                        instrument.price_precision(),
349                        instrument.size_precision(),
350                        ts_init,
351                    ) {
352                        Ok(tick) => Self::send_data(ctx, Data::Trade(tick)),
353                        Err(e) => log::warn!("Failed to parse Derive trade tick: {e}"),
354                    }
355                }
356            }
357            DerivePublicWsData::Ticker(msg) => {
358                let instrument_id = msg.data.instrument_id();
359
360                if !channel_is_active(
361                    &ctx.active_ticker_channels,
362                    instrument_id,
363                    msg.channel.as_str(),
364                ) {
365                    return;
366                }
367
368                let Some(instrument) = ctx.instruments.get_cloned(&instrument_id) else {
369                    log::warn!("Ticker message received for unknown instrument {instrument_id}");
370                    return;
371                };
372
373                let ts_init = ctx.clock.get_time_ns();
374                let price_precision = instrument.price_precision();
375
376                if ctx.active_quote_subs.contains(&instrument_id) {
377                    match process_ticker_quote(
378                        &msg,
379                        price_precision,
380                        instrument.size_precision(),
381                        ts_init,
382                        &mut ctx.quote_cache,
383                    ) {
384                        Ok(Some(quote)) => Self::send_data(ctx, Data::Quote(quote)),
385                        Ok(None) => {}
386                        Err(e) => log::warn!("Failed to parse Derive ticker quote: {e}"),
387                    }
388                }
389
390                if ctx.active_mark_subs.contains(&instrument_id) {
391                    match parse_mark_price(&msg, price_precision, ts_init) {
392                        Ok(Some(update)) => Self::send_data(ctx, Data::MarkPriceUpdate(update)),
393                        Ok(None) => {}
394                        Err(e) => log::warn!("Failed to parse Derive mark price: {e}"),
395                    }
396                }
397
398                if ctx.active_index_subs.contains(&instrument_id) {
399                    match parse_index_price(&msg, price_precision, ts_init) {
400                        Ok(Some(update)) => Self::send_data(ctx, Data::IndexPriceUpdate(update)),
401                        Ok(None) => {}
402                        Err(e) => log::warn!("Failed to parse Derive index price: {e}"),
403                    }
404                }
405
406                if ctx.active_funding_subs.contains(&instrument_id) {
407                    match parse_funding_rate(&msg, ts_init) {
408                        Ok(Some(update)) => {
409                            if let Err(e) = ctx.data_sender.send(DataEvent::FundingRate(update)) {
410                                log::error!("Failed to send Derive funding rate: {e}");
411                            }
412                        }
413                        Ok(None) => {}
414                        Err(e) => log::warn!("Failed to parse Derive funding rate: {e}"),
415                    }
416                }
417
418                if ctx.active_greeks_subs.contains(&instrument_id) {
419                    match parse_option_greeks(&msg, ts_init) {
420                        Ok(Some(greeks)) => {
421                            if let Err(e) = ctx.data_sender.send(DataEvent::OptionGreeks(greeks)) {
422                                log::error!("Failed to send Derive option greeks: {e}");
423                            }
424                        }
425                        Ok(None) => {}
426                        Err(e) => log::warn!("Failed to parse Derive option greeks: {e}"),
427                    }
428                }
429            }
430        }
431    }
432
433    fn send_data(ctx: &WsMessageContext, data: Data) {
434        if let Err(e) = ctx.data_sender.send(DataEvent::Data(data)) {
435            log::error!("Failed to send Derive data event: {e}");
436        }
437    }
438
439    fn cache_provider_instruments(&self) {
440        let instruments = self
441            .provider
442            .store()
443            .get_all()
444            .values()
445            .cloned()
446            .collect::<Vec<_>>();
447
448        for instrument in instruments {
449            self.cache_instrument(&instrument);
450            if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
451                log::warn!("Failed to send Derive instrument: {e}");
452            }
453        }
454    }
455
456    fn cache_instrument(&self, instrument: &InstrumentAny) {
457        cache_instrument(&self.instruments, instrument);
458    }
459
460    fn prepare_subscribe(&self, instrument_id: InstrumentId) -> anyhow::Result<bool> {
461        if self.instruments.contains_key(&instrument_id) {
462            return Ok(false);
463        }
464
465        if !self.config.auto_load_missing_instruments {
466            anyhow::bail!(
467                "Instrument {instrument_id} not found and `auto_load_missing_instruments` is disabled"
468            );
469        }
470        Ok(true)
471    }
472
473    async fn lazy_load_instrument(
474        http_client: DeriveHttpClient,
475        instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
476        instrument_id: InstrumentId,
477        include_expired: bool,
478    ) -> anyhow::Result<()> {
479        let currency = currency_from_instrument_id(&instrument_id)?;
480        let definitions = fetch_instrument_definitions(&http_client, currency, include_expired)
481            .await
482            .with_context(|| format!("failed to lazy-load Derive instruments for {currency}"))?;
483        let mut found = false;
484
485        for instrument in parse_instrument_definitions(definitions)? {
486            if instrument.id() == instrument_id {
487                found = true;
488            }
489            cache_instrument(&instruments, &instrument);
490        }
491
492        if !found {
493            anyhow::bail!(InstrumentLookupError::not_found(instrument_id));
494        }
495
496        Ok(())
497    }
498
499    fn ws_handle(&self) -> DeriveWebSocketSubscriptionHandle {
500        self.ws_client.subscription_handle()
501    }
502
503    fn feed_subs(&self, feed: TickerFeed) -> Arc<AtomicSet<InstrumentId>> {
504        match feed {
505            TickerFeed::Quote => Arc::clone(&self.active_quote_subs),
506            TickerFeed::Mark => Arc::clone(&self.active_mark_subs),
507            TickerFeed::Index => Arc::clone(&self.active_index_subs),
508            TickerFeed::Funding => Arc::clone(&self.active_funding_subs),
509            TickerFeed::Greeks => Arc::clone(&self.active_greeks_subs),
510        }
511    }
512
513    fn has_any_ticker_feed(&self, instrument_id: InstrumentId) -> bool {
514        self.active_quote_subs.contains(&instrument_id)
515            || self.active_mark_subs.contains(&instrument_id)
516            || self.active_index_subs.contains(&instrument_id)
517            || self.active_funding_subs.contains(&instrument_id)
518            || self.active_greeks_subs.contains(&instrument_id)
519    }
520
521    fn subscribe_ticker_feed(
522        &self,
523        instrument_id: InstrumentId,
524        params: &Option<Params>,
525        feed: TickerFeed,
526        label: &'static str,
527    ) -> anyhow::Result<()> {
528        let feed_subs = self.feed_subs(feed);
529        if feed_subs.contains(&instrument_id) {
530            return Ok(());
531        }
532
533        if self.active_ticker_channels.contains_key(&instrument_id) {
534            feed_subs.insert(instrument_id);
535            return Ok(());
536        }
537
538        let instrument_name = format_venue_symbol(&instrument_id)?.to_string();
539        let interval = ticker_interval(params)?;
540        let channel = ticker_channel(&instrument_name, &interval);
541        let needs_load = self.prepare_subscribe(instrument_id)?;
542        feed_subs.insert(instrument_id);
543        let ws = self.ws_handle();
544        let http_client = self.http_client.clone();
545        let include_expired = self.config.include_expired;
546        let instruments = Arc::clone(&self.instruments);
547        let active_ticker_channels = Arc::clone(&self.active_ticker_channels);
548        let active_quote_subs = Arc::clone(&self.active_quote_subs);
549        let active_mark_subs = Arc::clone(&self.active_mark_subs);
550        let active_index_subs = Arc::clone(&self.active_index_subs);
551        let active_funding_subs = Arc::clone(&self.active_funding_subs);
552        let active_greeks_subs = Arc::clone(&self.active_greeks_subs);
553        active_ticker_channels.insert(instrument_id, channel.clone());
554
555        self.spawn_task("subscribe_ticker_feed", async move {
556            if needs_load
557                && let Err(e) = Self::lazy_load_instrument(
558                    http_client,
559                    instruments,
560                    instrument_id,
561                    include_expired,
562                )
563                .await
564            {
565                rollback_ticker_subscription(
566                    &active_ticker_channels,
567                    &active_quote_subs,
568                    &active_mark_subs,
569                    &active_index_subs,
570                    &active_funding_subs,
571                    &active_greeks_subs,
572                    instrument_id,
573                    &channel,
574                );
575                log::error!("Lazy-load failed for {instrument_id} ({label}): {e}");
576                return Ok(());
577            }
578
579            if !channel_is_active(&active_ticker_channels, instrument_id, &channel) {
580                return Ok(());
581            }
582
583            if let Err(e) = ws.subscribe_ticker(&instrument_name, &interval).await {
584                rollback_ticker_subscription(
585                    &active_ticker_channels,
586                    &active_quote_subs,
587                    &active_mark_subs,
588                    &active_index_subs,
589                    &active_funding_subs,
590                    &active_greeks_subs,
591                    instrument_id,
592                    &channel,
593                );
594                log::error!("Failed to subscribe to Derive {label} for {instrument_id}: {e}");
595            }
596            Ok(())
597        });
598
599        Ok(())
600    }
601
602    fn unsubscribe_ticker_feed(&self, instrument_id: InstrumentId, feed: TickerFeed) {
603        let feed_subs = self.feed_subs(feed);
604        if !feed_subs.contains(&instrument_id) {
605            return;
606        }
607        feed_subs.remove(&instrument_id);
608
609        if self.has_any_ticker_feed(instrument_id) {
610            return;
611        }
612
613        let Some(channel) = self.active_ticker_channels.get_cloned(&instrument_id) else {
614            return;
615        };
616        self.active_ticker_channels.remove(&instrument_id);
617
618        let (instrument_name, interval) = match ticker_channel_parts(&channel) {
619            Ok(parts) => parts,
620            Err(e) => {
621                log::error!("Invalid Derive ticker channel `{channel}`: {e}");
622                return;
623            }
624        };
625        let ws = self.ws_handle();
626
627        self.spawn_task("unsubscribe_ticker_feed", async move {
628            if let Err(e) = ws.unsubscribe_ticker(&instrument_name, &interval).await {
629                log::error!("Failed to unsubscribe from Derive ticker for {instrument_id}: {e}");
630            }
631            Ok(())
632        });
633    }
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq)]
637enum TickerFeed {
638    Quote,
639    Mark,
640    Index,
641    Funding,
642    Greeks,
643}
644
645#[async_trait(?Send)]
646impl DataClient for DeriveDataClient {
647    fn client_id(&self) -> ClientId {
648        self.client_id
649    }
650
651    fn venue(&self) -> Option<Venue> {
652        Some(*DERIVE_VENUE)
653    }
654
655    fn start(&mut self) -> anyhow::Result<()> {
656        log::info!("Starting Derive data client: {}", self.client_id);
657        Ok(())
658    }
659
660    fn stop(&mut self) -> anyhow::Result<()> {
661        log::info!("Stopping Derive data client: {}", self.client_id);
662        self.cancellation_token.cancel();
663        self.is_connected.store(false, Ordering::Relaxed);
664        Ok(())
665    }
666
667    fn reset(&mut self) -> anyhow::Result<()> {
668        log::info!("Resetting Derive data client: {}", self.client_id);
669        self.cancellation_token.cancel();
670
671        self.abort_pending_tasks();
672
673        if let Some(handle) = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take() {
674            handle.abort();
675        }
676
677        // Leave the cancellation token cancelled; connect() refreshes it
678        // (and tears down the inner WS client) on the next lifecycle start.
679        self.instruments.store(AHashMap::new());
680        self.clear_subscription_state();
681        self.provider.store_mut().clear();
682        self.is_connected.store(false, Ordering::Relaxed);
683        Ok(())
684    }
685
686    fn dispose(&mut self) -> anyhow::Result<()> {
687        log::debug!("Disposing Derive data client: {}", self.client_id);
688        self.stop()
689    }
690
691    fn is_connected(&self) -> bool {
692        self.is_connected.load(Ordering::SeqCst)
693    }
694
695    fn is_disconnected(&self) -> bool {
696        !self.is_connected()
697    }
698
699    async fn connect(&mut self) -> anyhow::Result<()> {
700        if self.is_connected() {
701            return Ok(());
702        }
703
704        // Completes the async teardown deferred by sync reset()/stop().
705        if self.cancellation_token.is_cancelled() {
706            if let Err(e) = self.ws_client.disconnect().await {
707                log::debug!("Error tearing down WebSocket on reconnect: {e}");
708            }
709            self.abort_pending_tasks();
710            self.clear_subscription_state();
711            self.cancellation_token = CancellationToken::new();
712        }
713
714        if !self.config.currencies.is_empty() {
715            self.provider
716                .load_all(None)
717                .await
718                .context("failed to load Derive instruments")?;
719            self.cache_provider_instruments();
720        }
721
722        self.ws_client
723            .connect()
724            .await
725            .context("failed to connect Derive WebSocket")?;
726        let rx = self
727            .ws_client
728            .take_event_receiver()
729            .ok_or_else(|| anyhow::anyhow!("Derive WebSocket event receiver not initialized"))?;
730        self.spawn_stream_task(rx);
731
732        self.is_connected.store(true, Ordering::Release);
733        log::info!(
734            "Connected Derive data client ({:?})",
735            self.config.environment
736        );
737        Ok(())
738    }
739
740    async fn disconnect(&mut self) -> anyhow::Result<()> {
741        if self.is_disconnected() {
742            return Ok(());
743        }
744
745        self.cancellation_token.cancel();
746
747        if let Err(e) = self.ws_client.disconnect().await {
748            log::warn!("Error while disconnecting Derive WebSocket: {e}");
749        }
750
751        // Await the WS consumption loop so its sender is dropped before we
752        // return; abort the request-handler tasks since they don't observe
753        // the cancellation token and would otherwise outlive the client.
754        let ws_handle = self.ws_stream_handle.lock().expect(MUTEX_POISONED).take();
755        if let Some(handle) = ws_handle
756            && let Err(e) = handle.await
757        {
758            log::error!("Error joining Derive WebSocket data task: {e:?}");
759        }
760        self.abort_pending_tasks();
761
762        // Aborting in-flight subscribe tasks skips their on-error rollback,
763        // so any `active_*` entries staged before spawn would leak across
764        // a reconnect and silently suppress the next subscribe. Clear the
765        // local subscription state to match the venue-side reality that
766        // disconnect drops all subscriptions.
767        self.clear_subscription_state();
768
769        self.is_connected.store(false, Ordering::Relaxed);
770        log::info!("Disconnected Derive data client");
771        Ok(())
772    }
773
774    fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
775        if cmd.book_type != BookType::L2_MBP {
776            anyhow::bail!("Derive only supports L2_MBP order book deltas");
777        }
778
779        let instrument_id = cmd.instrument_id;
780        if self.active_book_delta_channels.contains_key(&instrument_id) {
781            return Ok(());
782        }
783
784        let instrument_name = format_venue_symbol(&instrument_id)?.to_string();
785        let group = orderbook_group(&cmd.params)?;
786        let depth = orderbook_depth(cmd.depth.map(|d| d.get()), &cmd.params)?;
787        let channel = orderbook_channel(&instrument_name, &group, &depth);
788        let needs_load = self.prepare_subscribe(instrument_id)?;
789        let ws = self.ws_handle();
790        let http_client = self.http_client.clone();
791        let include_expired = self.config.include_expired;
792        let instruments = Arc::clone(&self.instruments);
793        let active_book_delta_channels = Arc::clone(&self.active_book_delta_channels);
794        active_book_delta_channels.insert(instrument_id, channel.clone());
795
796        self.spawn_task("subscribe_book_deltas", async move {
797            if needs_load
798                && let Err(e) = Self::lazy_load_instrument(
799                    http_client,
800                    instruments,
801                    instrument_id,
802                    include_expired,
803                )
804                .await
805            {
806                remove_channel_if_matches(&active_book_delta_channels, instrument_id, &channel);
807                log::error!("Lazy-load failed for {instrument_id} (book deltas): {e}");
808                return Ok(());
809            }
810
811            if !channel_is_active(&active_book_delta_channels, instrument_id, &channel) {
812                return Ok(());
813            }
814
815            if let Err(e) = ws
816                .subscribe_orderbook(&instrument_name, &group, &depth)
817                .await
818            {
819                remove_channel_if_matches(&active_book_delta_channels, instrument_id, &channel);
820                log::error!("Failed to subscribe to Derive book deltas for {instrument_id}: {e}");
821            }
822            Ok(())
823        });
824
825        Ok(())
826    }
827
828    fn subscribe_book_depth10(&mut self, cmd: SubscribeBookDepth10) -> anyhow::Result<()> {
829        if cmd.book_type != BookType::L2_MBP {
830            anyhow::bail!("Derive only supports L2_MBP order book depth");
831        }
832
833        let instrument_id = cmd.instrument_id;
834
835        if self
836            .active_book_depth10_channels
837            .contains_key(&instrument_id)
838        {
839            return Ok(());
840        }
841
842        let instrument_name = format_venue_symbol(&instrument_id)?.to_string();
843        let group = orderbook_group(&cmd.params)?;
844        let depth = DeriveOrderbookDepth::D10.to_string();
845        let channel = orderbook_channel(&instrument_name, &group, &depth);
846        let needs_load = self.prepare_subscribe(instrument_id)?;
847        let ws = self.ws_handle();
848        let http_client = self.http_client.clone();
849        let include_expired = self.config.include_expired;
850        let instruments = Arc::clone(&self.instruments);
851        let active_book_depth10_channels = Arc::clone(&self.active_book_depth10_channels);
852        active_book_depth10_channels.insert(instrument_id, channel.clone());
853
854        self.spawn_task("subscribe_book_depth10", async move {
855            if needs_load
856                && let Err(e) = Self::lazy_load_instrument(
857                    http_client,
858                    instruments,
859                    instrument_id,
860                    include_expired,
861                )
862                .await
863            {
864                remove_channel_if_matches(&active_book_depth10_channels, instrument_id, &channel);
865                log::error!("Lazy-load failed for {instrument_id} (book depth10): {e}");
866                return Ok(());
867            }
868
869            if !channel_is_active(&active_book_depth10_channels, instrument_id, &channel) {
870                return Ok(());
871            }
872
873            if let Err(e) = ws
874                .subscribe_orderbook(&instrument_name, &group, &depth)
875                .await
876            {
877                remove_channel_if_matches(&active_book_depth10_channels, instrument_id, &channel);
878                log::error!("Failed to subscribe to Derive book depth10 for {instrument_id}: {e}");
879            }
880            Ok(())
881        });
882
883        Ok(())
884    }
885
886    fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
887        self.subscribe_ticker_feed(cmd.instrument_id, &cmd.params, TickerFeed::Quote, "quotes")
888    }
889
890    fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
891        let instrument_id = cmd.instrument_id;
892        if self.active_trade_subs.contains(&instrument_id) {
893            return Ok(());
894        }
895
896        let needs_load = self.prepare_subscribe(instrument_id)?;
897        let ws = self.ws_handle();
898        let http_client = self.http_client.clone();
899        let include_expired = self.config.include_expired;
900        let instruments = Arc::clone(&self.instruments);
901        let active_trade_subs = Arc::clone(&self.active_trade_subs);
902        let active_trade_channels = Arc::clone(&self.active_trade_channels);
903        active_trade_subs.insert(instrument_id);
904
905        self.spawn_task("subscribe_trades", async move {
906            if needs_load
907                && let Err(e) = Self::lazy_load_instrument(
908                    http_client,
909                    Arc::clone(&instruments),
910                    instrument_id,
911                    include_expired,
912                )
913                .await
914            {
915                active_trade_subs.remove(&instrument_id);
916                log::error!("Lazy-load failed for {instrument_id} (trades): {e}");
917                return Ok(());
918            }
919
920            if !active_trade_subs.contains(&instrument_id) {
921                return Ok(());
922            }
923
924            let Some(instrument) = instruments.get_cloned(&instrument_id) else {
925                active_trade_subs.remove(&instrument_id);
926                log::error!("Instrument {instrument_id} not found for Derive trades");
927                return Ok(());
928            };
929            let channel = match trade_channel(&instrument) {
930                Ok(channel) => channel,
931                Err(e) => {
932                    active_trade_subs.remove(&instrument_id);
933                    log::error!("Failed to resolve Derive trades channel: {e}");
934                    return Ok(());
935                }
936            };
937
938            if active_trade_channels.insert(channel.clone(), ()).is_some() {
939                return Ok(());
940            }
941
942            let Some((instrument_type, currency)) = channel
943                .strip_prefix("trades.")
944                .and_then(|s| s.split_once('.'))
945            else {
946                active_trade_subs.remove(&instrument_id);
947                active_trade_channels.remove(&channel);
948                log::error!("Invalid Derive trades channel `{channel}`");
949                return Ok(());
950            };
951
952            if let Err(e) = ws.subscribe_trades(instrument_type, currency).await {
953                active_trade_subs.remove(&instrument_id);
954                active_trade_channels.remove(&channel);
955                log::error!("Failed to subscribe to Derive trades for {instrument_id}: {e}");
956            }
957            Ok(())
958        });
959
960        Ok(())
961    }
962
963    fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
964        self.subscribe_ticker_feed(
965            cmd.instrument_id,
966            &cmd.params,
967            TickerFeed::Mark,
968            "mark prices",
969        )
970    }
971
972    fn subscribe_index_prices(&mut self, cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
973        self.subscribe_ticker_feed(
974            cmd.instrument_id,
975            &cmd.params,
976            TickerFeed::Index,
977            "index prices",
978        )
979    }
980
981    fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
982        self.subscribe_ticker_feed(
983            cmd.instrument_id,
984            &cmd.params,
985            TickerFeed::Funding,
986            "funding rates",
987        )
988    }
989
990    fn subscribe_option_greeks(&mut self, cmd: SubscribeOptionGreeks) -> anyhow::Result<()> {
991        self.subscribe_ticker_feed(
992            cmd.instrument_id,
993            &cmd.params,
994            TickerFeed::Greeks,
995            "option greeks",
996        )
997    }
998
999    fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
1000        let instrument_id = cmd.instrument_id;
1001        let Some(channel) = self.active_book_delta_channels.get_cloned(&instrument_id) else {
1002            return Ok(());
1003        };
1004        self.active_book_delta_channels.remove(&instrument_id);
1005
1006        let (instrument_name, group, depth) = orderbook_channel_parts(&channel)?;
1007        let ws = self.ws_handle();
1008
1009        self.spawn_task("unsubscribe_book_deltas", async move {
1010            if let Err(e) = ws
1011                .unsubscribe_orderbook(&instrument_name, &group, &depth)
1012                .await
1013            {
1014                log::error!(
1015                    "Failed to unsubscribe from Derive book deltas for {instrument_id}: {e}"
1016                );
1017            }
1018            Ok(())
1019        });
1020
1021        Ok(())
1022    }
1023
1024    fn unsubscribe_book_depth10(&mut self, cmd: &UnsubscribeBookDepth10) -> anyhow::Result<()> {
1025        let instrument_id = cmd.instrument_id;
1026        let Some(channel) = self.active_book_depth10_channels.get_cloned(&instrument_id) else {
1027            return Ok(());
1028        };
1029        self.active_book_depth10_channels.remove(&instrument_id);
1030
1031        let (instrument_name, group, depth) = orderbook_channel_parts(&channel)?;
1032        let ws = self.ws_handle();
1033
1034        self.spawn_task("unsubscribe_book_depth10", async move {
1035            if let Err(e) = ws
1036                .unsubscribe_orderbook(&instrument_name, &group, &depth)
1037                .await
1038            {
1039                log::error!(
1040                    "Failed to unsubscribe from Derive book depth10 for {instrument_id}: {e}"
1041                );
1042            }
1043            Ok(())
1044        });
1045
1046        Ok(())
1047    }
1048
1049    fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
1050        self.unsubscribe_ticker_feed(cmd.instrument_id, TickerFeed::Quote);
1051        Ok(())
1052    }
1053
1054    fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
1055        let instrument_id = cmd.instrument_id;
1056        let Some(instrument) = self.instruments.get_cloned(&instrument_id) else {
1057            self.active_trade_subs.remove(&instrument_id);
1058            return Ok(());
1059        };
1060        let channel = trade_channel(&instrument)?;
1061
1062        self.active_trade_subs.remove(&instrument_id);
1063        if active_trade_channel_count(&self.instruments, &self.active_trade_subs, &channel) > 0 {
1064            return Ok(());
1065        }
1066
1067        if self.active_trade_channels.remove(&channel).is_none() {
1068            return Ok(());
1069        }
1070
1071        let (instrument_type, currency) = channel
1072            .strip_prefix("trades.")
1073            .and_then(|s| s.split_once('.'))
1074            .ok_or_else(|| anyhow::anyhow!("invalid Derive trades channel `{channel}`"))?;
1075        let instrument_type = instrument_type.to_string();
1076        let currency = currency.to_string();
1077        let ws = self.ws_handle();
1078
1079        self.spawn_task("unsubscribe_trades", async move {
1080            if let Err(e) = ws.unsubscribe_trades(&instrument_type, &currency).await {
1081                log::error!("Failed to unsubscribe from Derive trades for {instrument_id}: {e}");
1082            }
1083            Ok(())
1084        });
1085
1086        Ok(())
1087    }
1088
1089    fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
1090        self.unsubscribe_ticker_feed(cmd.instrument_id, TickerFeed::Mark);
1091        Ok(())
1092    }
1093
1094    fn unsubscribe_index_prices(&mut self, cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
1095        self.unsubscribe_ticker_feed(cmd.instrument_id, TickerFeed::Index);
1096        Ok(())
1097    }
1098
1099    fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
1100        self.unsubscribe_ticker_feed(cmd.instrument_id, TickerFeed::Funding);
1101        Ok(())
1102    }
1103
1104    fn unsubscribe_option_greeks(&mut self, cmd: &UnsubscribeOptionGreeks) -> anyhow::Result<()> {
1105        self.unsubscribe_ticker_feed(cmd.instrument_id, TickerFeed::Greeks);
1106        Ok(())
1107    }
1108
1109    fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
1110        // No historical quote endpoint; `public/get_tickers` is a current snapshot.
1111        let instrument_id = request.instrument_id;
1112        let instrument = self
1113            .instruments
1114            .get_cloned(&instrument_id)
1115            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1116        let venue_symbol = format_venue_symbol(&instrument_id)?.to_string();
1117        let price_precision = instrument.price_precision();
1118        let size_precision = instrument.size_precision();
1119
1120        let http_client = self.http_client.clone();
1121        let sender = self.data_sender.clone();
1122        let clock = self.clock;
1123        let client_id = request.client_id.unwrap_or(self.client_id);
1124        let request_id = request.request_id;
1125        let params = request.params;
1126        let start_nanos = datetime_to_unix_nanos(request.start);
1127        let end_nanos = datetime_to_unix_nanos(request.end);
1128
1129        self.spawn_task("request_quotes", async move {
1130            let ticker = match http_client.get_ticker(&venue_symbol).await {
1131                Ok(ticker) => ticker,
1132                Err(e) => {
1133                    log::error!("Failed to fetch Derive ticker for {instrument_id}: {e:?}");
1134                    return Ok(());
1135                }
1136            };
1137
1138            let ts_init = clock.get_time_ns();
1139            let quotes = match parse_ticker_quote_from_rest(
1140                &ticker,
1141                price_precision,
1142                size_precision,
1143                ts_init,
1144            ) {
1145                Ok(quote) => {
1146                    // Drop the snapshot when bounded callers ask for a past window.
1147                    let within_start = start_nanos.is_none_or(|nanos| quote.ts_event >= nanos);
1148                    let within_end = end_nanos.is_none_or(|nanos| quote.ts_event <= nanos);
1149                    if within_start && within_end {
1150                        vec![quote]
1151                    } else {
1152                        Vec::new()
1153                    }
1154                }
1155                Err(e) => {
1156                    log::warn!("Failed to parse Derive ticker for {instrument_id}: {e}");
1157                    Vec::new()
1158                }
1159            };
1160
1161            let response = DataResponse::Quotes(QuotesResponse::new(
1162                request_id,
1163                client_id,
1164                instrument_id,
1165                quotes,
1166                start_nanos,
1167                end_nanos,
1168                clock.get_time_ns(),
1169                params,
1170            ));
1171
1172            if let Err(e) = sender.send(DataEvent::Response(response)) {
1173                log::error!("Failed to send Derive quotes response: {e}");
1174            }
1175            Ok(())
1176        });
1177
1178        Ok(())
1179    }
1180
1181    fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1182        let instrument_id = request.instrument_id;
1183        let instrument = self
1184            .instruments
1185            .get_cloned(&instrument_id)
1186            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1187        let venue_symbol = format_venue_symbol(&instrument_id)?.to_string();
1188        let price_precision = instrument.price_precision();
1189        let size_precision = instrument.size_precision();
1190
1191        let http_client = self.http_client.clone();
1192        let sender = self.data_sender.clone();
1193        let clock = self.clock;
1194        let client_id = request.client_id.unwrap_or(self.client_id);
1195        let request_id = request.request_id;
1196        let params = request.params;
1197        let start = request.start;
1198        let end = request.end;
1199        let limit = request.limit.map(NonZeroUsize::get);
1200        let start_nanos = datetime_to_unix_nanos(start);
1201        let end_nanos = datetime_to_unix_nanos(end);
1202        let from_timestamp = start.map(|dt| dt.timestamp_millis());
1203        let to_timestamp = end.map(|dt| dt.timestamp_millis());
1204
1205        self.spawn_task("request_trades", async move {
1206            // Hold page_size constant across requests: the venue paginates by
1207            // offset = (page - 1) * page_size, so shrinking page_size mid-walk
1208            // would skip and duplicate trades.
1209            let page_size = limit.map_or(DERIVE_TRADES_PAGE_SIZE, |cap| {
1210                cap.min(DERIVE_TRADES_PAGE_SIZE as usize) as u32
1211            });
1212            let mut trades = Vec::new();
1213            let mut page = 1u32;
1214
1215            loop {
1216                let result = match http_client
1217                    .get_trade_history(&venue_symbol, from_timestamp, to_timestamp, page, page_size)
1218                    .await
1219                {
1220                    Ok(result) => result,
1221                    Err(e) => {
1222                        log::error!("Failed to fetch Derive trades for {instrument_id}: {e:?}");
1223                        return Ok(());
1224                    }
1225                };
1226
1227                if result.trades.is_empty() {
1228                    break;
1229                }
1230
1231                let num_pages = result.pagination.num_pages;
1232                let ts_init = clock.get_time_ns();
1233
1234                for trade in &result.trades {
1235                    if let Some(cap) = limit
1236                        && trades.len() >= cap
1237                    {
1238                        break;
1239                    }
1240
1241                    match parse_trade_tick(trade, price_precision, size_precision, ts_init) {
1242                        Ok(tick) => trades.push(tick),
1243                        Err(e) => log::warn!(
1244                            "Failed to parse Derive trade {} for {instrument_id}: {e}",
1245                            trade.trade_id,
1246                        ),
1247                    }
1248                }
1249
1250                if let Some(cap) = limit
1251                    && trades.len() >= cap
1252                {
1253                    break;
1254                }
1255
1256                if (page as i64) >= num_pages {
1257                    break;
1258                }
1259                page += 1;
1260            }
1261
1262            let response = DataResponse::Trades(TradesResponse::new(
1263                request_id,
1264                client_id,
1265                instrument_id,
1266                trades,
1267                start_nanos,
1268                end_nanos,
1269                clock.get_time_ns(),
1270                params,
1271            ));
1272
1273            if let Err(e) = sender.send(DataEvent::Response(response)) {
1274                log::error!("Failed to send Derive trades response: {e}");
1275            }
1276            Ok(())
1277        });
1278
1279        Ok(())
1280    }
1281
1282    fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1283        let instrument_id = request.instrument_id;
1284        let instrument = self
1285            .instruments
1286            .get_cloned(&instrument_id)
1287            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1288        anyhow::ensure!(
1289            matches!(instrument, InstrumentAny::CryptoPerpetual(_)),
1290            "Funding rates are only available for Derive perpetual instruments (got {instrument_id})",
1291        );
1292        let venue_symbol = format_venue_symbol(&instrument_id)?.to_string();
1293
1294        let http_client = self.http_client.clone();
1295        let sender = self.data_sender.clone();
1296        let clock = self.clock;
1297        let client_id = request.client_id.unwrap_or(self.client_id);
1298        let request_id = request.request_id;
1299        let params = request.params;
1300        let start = request.start;
1301        let end = request.end;
1302        let limit = request.limit.map(NonZeroUsize::get);
1303        let start_nanos = datetime_to_unix_nanos(start);
1304        let end_nanos = datetime_to_unix_nanos(end);
1305        let start_ms = start.map(|dt| dt.timestamp_millis());
1306        let end_ms = end.map(|dt| dt.timestamp_millis());
1307
1308        self.spawn_task("request_funding_rates", async move {
1309            let result = match http_client
1310                .get_funding_rate_history(&venue_symbol, start_ms, end_ms, None)
1311                .await
1312            {
1313                Ok(result) => result,
1314                Err(e) => {
1315                    log::error!(
1316                        "Failed to fetch Derive funding rate history for {instrument_id}: {e:?}",
1317                    );
1318                    return Ok(());
1319                }
1320            };
1321
1322            let ts_init = clock.get_time_ns();
1323            let mut updates = Vec::with_capacity(result.funding_rate_history.len());
1324
1325            for record in &result.funding_rate_history {
1326                if let Some(cap) = limit
1327                    && updates.len() >= cap
1328                {
1329                    break;
1330                }
1331
1332                match parse_funding_rate_history_record(record, instrument_id, None, ts_init) {
1333                    Ok(update) => updates.push(update),
1334                    Err(e) => log::warn!(
1335                        "Failed to parse Derive funding rate record for {instrument_id} at {}: {e}",
1336                        record.timestamp,
1337                    ),
1338                }
1339            }
1340
1341            let response = DataResponse::FundingRates(FundingRatesResponse::new(
1342                request_id,
1343                client_id,
1344                instrument_id,
1345                updates,
1346                start_nanos,
1347                end_nanos,
1348                clock.get_time_ns(),
1349                params,
1350            ));
1351
1352            if let Err(e) = sender.send(DataEvent::Response(response)) {
1353                log::error!("Failed to send Derive funding rates response: {e}");
1354            }
1355            Ok(())
1356        });
1357
1358        Ok(())
1359    }
1360
1361    fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1362        let bar_type = request.bar_type;
1363        anyhow::ensure!(
1364            bar_type.aggregation_source() == AggregationSource::External,
1365            "Derive only supports EXTERNAL aggregation source (got {bar_type})",
1366        );
1367        let spec = bar_type.spec();
1368        anyhow::ensure!(
1369            spec.price_type == PriceType::Last,
1370            "Derive candles are trade-based; only PriceType::Last is supported (got {bar_type})",
1371        );
1372
1373        let instrument_id = bar_type.instrument_id();
1374        let instrument = self
1375            .instruments
1376            .get_cloned(&instrument_id)
1377            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1378        let venue_symbol = format_venue_symbol(&instrument_id)?.to_string();
1379        let price_precision = instrument.price_precision();
1380        let size_precision = instrument.size_precision();
1381
1382        let period = bar_spec_to_derive_period(spec.aggregation, spec.step.get() as u64)
1383            .with_context(|| format!("unsupported Derive bar spec for {bar_type}"))?;
1384
1385        let http_client = self.http_client.clone();
1386        let sender = self.data_sender.clone();
1387        let clock = self.clock;
1388        let client_id = request.client_id.unwrap_or(self.client_id);
1389        let request_id = request.request_id;
1390        let params = request.params;
1391        let start = request.start;
1392        let end = request.end;
1393        let limit = request.limit.map(NonZeroUsize::get);
1394        let start_nanos = datetime_to_unix_nanos(start);
1395        let end_nanos = datetime_to_unix_nanos(end);
1396
1397        // The venue requires both bounds in UNIX seconds. Default end to now
1398        // and start to one window of `limit` buckets (or 1000) before end.
1399        let now_secs = (clock.get_time_ns().as_u64() / NANOSECONDS_IN_SECOND) as i64;
1400        let end_ts = end.map_or(now_secs, |dt| dt.timestamp());
1401        let default_span = i64::from(period) * limit.unwrap_or(DERIVE_CANDLES_DEFAULT_LIMIT) as i64;
1402        let start_ts = start.map_or(end_ts - default_span, |dt| dt.timestamp());
1403
1404        self.spawn_task("request_bars", async move {
1405            // Venue caps each call at 5000 candles; walk backwards by shrinking
1406            // `current_end` to one second before each page's earliest bucket.
1407            let mut seen_timestamps: AHashSet<i64> = AHashSet::new();
1408            let mut pages: Vec<Vec<Bar>> = Vec::new();
1409            let mut total_bars = 0usize;
1410            let mut current_end = end_ts;
1411            let mut page_count = 0;
1412
1413            loop {
1414                page_count += 1;
1415
1416                let mut records = match http_client
1417                    .get_candles(&venue_symbol, start_ts, current_end, period)
1418                    .await
1419                {
1420                    Ok(records) => records,
1421                    Err(e) => {
1422                        log::error!("Failed to fetch Derive candles for {bar_type}: {e:?}");
1423                        return Ok(());
1424                    }
1425                };
1426
1427                if records.is_empty() {
1428                    break;
1429                }
1430
1431                records.sort_by_key(|r| r.timestamp_bucket);
1432
1433                let has_new = records
1434                    .iter()
1435                    .any(|r| !seen_timestamps.contains(&r.timestamp_bucket));
1436
1437                if !has_new {
1438                    break;
1439                }
1440
1441                let ts_init = clock.get_time_ns();
1442                let mut page_bars = Vec::with_capacity(records.len());
1443                let mut earliest_ts: Option<i64> = None;
1444
1445                for record in &records {
1446                    let bucket = record.timestamp_bucket;
1447                    if earliest_ts.is_none_or(|ts| bucket < ts) {
1448                        earliest_ts = Some(bucket);
1449                    }
1450
1451                    if seen_timestamps.contains(&bucket) {
1452                        continue;
1453                    }
1454
1455                    match parse_candle_record(
1456                        record,
1457                        bar_type,
1458                        price_precision,
1459                        size_precision,
1460                        ts_init,
1461                    ) {
1462                        Ok(bar) => {
1463                            page_bars.push(bar);
1464                            seen_timestamps.insert(bucket);
1465                        }
1466                        Err(e) => log::warn!(
1467                            "Failed to parse Derive candle for {bar_type} at {bucket}: {e}",
1468                        ),
1469                    }
1470                }
1471
1472                total_bars += page_bars.len();
1473                pages.push(page_bars);
1474
1475                if let Some(cap) = limit
1476                    && total_bars >= cap
1477                {
1478                    break;
1479                }
1480
1481                let Some(earliest) = earliest_ts else {
1482                    break;
1483                };
1484
1485                if earliest <= start_ts {
1486                    break;
1487                }
1488
1489                current_end = earliest - 1;
1490
1491                if page_count >= DERIVE_CANDLES_MAX_PAGES {
1492                    log::warn!(
1493                        "Derive bars pagination hit safety cap of {DERIVE_CANDLES_MAX_PAGES} pages for {bar_type}",
1494                    );
1495                    break;
1496                }
1497            }
1498
1499            let mut bars: Vec<Bar> = Vec::with_capacity(total_bars);
1500            for page in pages.into_iter().rev() {
1501                bars.extend(page);
1502            }
1503
1504            if let Some(cap) = limit
1505                && bars.len() > cap
1506            {
1507                let drop_count = bars.len() - cap;
1508                bars.drain(..drop_count);
1509            }
1510
1511            let response = DataResponse::Bars(BarsResponse::new(
1512                request_id,
1513                client_id,
1514                bar_type,
1515                bars,
1516                start_nanos,
1517                end_nanos,
1518                clock.get_time_ns(),
1519                params,
1520            ));
1521
1522            if let Err(e) = sender.send(DataEvent::Response(response)) {
1523                log::error!("Failed to send Derive bars response: {e}");
1524            }
1525            Ok(())
1526        });
1527
1528        Ok(())
1529    }
1530
1531    fn request_forward_prices(&self, request: RequestForwardPrices) -> anyhow::Result<()> {
1532        // The DataEngine drives this from `subscribe_option_chain` to bootstrap
1533        // the ATM price for the option series. It passes one option instrument
1534        // from the target series; that instrument's ticker carries the forward
1535        // price for every option at the same expiry. Bulk mode is unsupported
1536        // because Derive has no per-currency ticker endpoint.
1537        let Some(instrument_id) = request.instrument_id else {
1538            anyhow::bail!(
1539                "Derive request_forward_prices requires an `instrument_id`; bulk fetch is not supported",
1540            );
1541        };
1542        let instrument = self
1543            .instruments
1544            .get_cloned(&instrument_id)
1545            .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1546        anyhow::ensure!(
1547            matches!(instrument, InstrumentAny::CryptoOption(_)),
1548            "Derive forward prices are only meaningful for options (got {instrument_id})",
1549        );
1550        let venue_symbol = format_venue_symbol(&instrument_id)?.to_string();
1551
1552        let http_client = self.http_client.clone();
1553        let sender = self.data_sender.clone();
1554        let clock = self.clock;
1555        let client_id = request.client_id.unwrap_or(self.client_id);
1556        let request_id = request.request_id;
1557        let venue = request.venue;
1558        let underlying = request.underlying;
1559        let params = request.params;
1560
1561        self.spawn_task("request_forward_prices", async move {
1562            // The engine inserts this request into `pending_option_chain_requests`
1563            // and blocks `OptionChainManager` creation until a response arrives.
1564            // Always emit a response so the engine can fall back to live-tick
1565            // bootstrap when the REST ticker is unavailable or non-option.
1566            let forwards: Vec<ForwardPrice> = match http_client.get_ticker(&venue_symbol).await {
1567                Ok(ticker) => match ticker.option_pricing.as_ref() {
1568                    Some(pricing) => {
1569                        let ts_event = clock.get_time_ns();
1570                        vec![ForwardPrice::new(
1571                            instrument_id,
1572                            pricing.forward_price,
1573                            Some(underlying.to_string()),
1574                            ts_event,
1575                            ts_event,
1576                        )]
1577                    }
1578                    None => {
1579                        log::warn!(
1580                            "Derive ticker for {instrument_id} has no option_pricing; emitting empty forward prices",
1581                        );
1582                        Vec::new()
1583                    }
1584                },
1585                Err(e) => {
1586                    log::error!(
1587                        "Failed to fetch Derive ticker for {instrument_id}: {e:?}; emitting empty forward prices",
1588                    );
1589                    Vec::new()
1590                }
1591            };
1592
1593            let response = DataResponse::ForwardPrices(ForwardPricesResponse::new(
1594                request_id,
1595                client_id,
1596                venue,
1597                forwards,
1598                clock.get_time_ns(),
1599                params,
1600            ));
1601
1602            if let Err(e) = sender.send(DataEvent::Response(response)) {
1603                log::error!("Failed to send Derive forward prices response: {e}");
1604            }
1605            Ok(())
1606        });
1607
1608        Ok(())
1609    }
1610
1611    fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1612        let currencies = self.config.currencies.clone();
1613        if currencies.is_empty() {
1614            anyhow::bail!(
1615                "Derive request_instruments requires at least one configured currency \
1616                 (DeriveDataClientConfig::currencies)"
1617            );
1618        }
1619
1620        let http_client = self.http_client.clone();
1621        let include_expired = self.config.include_expired;
1622        let instruments_cache = Arc::clone(&self.instruments);
1623        let sender = self.data_sender.clone();
1624        let clock = self.clock;
1625        let venue = self.venue().unwrap_or(*DERIVE_VENUE);
1626        let client_id = request.client_id.unwrap_or(self.client_id);
1627        let request_id = request.request_id;
1628        let start_nanos = datetime_to_unix_nanos(request.start);
1629        let end_nanos = datetime_to_unix_nanos(request.end);
1630        let params = request.params;
1631
1632        self.spawn_task("request_instruments", async move {
1633            let mut all_instruments = Vec::new();
1634
1635            for currency in currencies {
1636                match fetch_instrument_definitions(&http_client, &currency, include_expired).await {
1637                    Ok(definitions) => match parse_instrument_definitions(definitions) {
1638                        Ok(instruments) => {
1639                            for instrument in instruments {
1640                                cache_instrument(&instruments_cache, &instrument);
1641                                all_instruments.push(instrument);
1642                            }
1643                        }
1644                        Err(e) => {
1645                            log::error!("Failed to parse Derive instruments for {currency}: {e}");
1646                        }
1647                    },
1648                    Err(e) => {
1649                        log::error!("Failed to fetch Derive instruments for {currency}: {e:?}");
1650                    }
1651                }
1652            }
1653
1654            let response = DataResponse::Instruments(InstrumentsResponse::new(
1655                request_id,
1656                client_id,
1657                venue,
1658                all_instruments,
1659                start_nanos,
1660                end_nanos,
1661                clock.get_time_ns(),
1662                params,
1663            ));
1664
1665            if let Err(e) = sender.send(DataEvent::Response(response)) {
1666                log::error!("Failed to send Derive instruments response: {e}");
1667            }
1668            Ok(())
1669        });
1670
1671        Ok(())
1672    }
1673
1674    fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1675        let instrument_id = request.instrument_id;
1676        let venue_symbol = format_venue_symbol(&instrument_id)?.to_string();
1677
1678        let http_client = self.http_client.clone();
1679        let instruments_cache = Arc::clone(&self.instruments);
1680        let sender = self.data_sender.clone();
1681        let clock = self.clock;
1682        let client_id = request.client_id.unwrap_or(self.client_id);
1683        let request_id = request.request_id;
1684        let start_nanos = datetime_to_unix_nanos(request.start);
1685        let end_nanos = datetime_to_unix_nanos(request.end);
1686        let params = request.params;
1687
1688        self.spawn_task("request_instrument", async move {
1689            let definition = match http_client.get_instrument(&venue_symbol).await {
1690                Ok(definition) => definition,
1691                Err(e) => {
1692                    log::error!("Failed to fetch Derive instrument {instrument_id}: {e:?}");
1693                    return Ok(());
1694                }
1695            };
1696
1697            let ts_init = clock.get_time_ns();
1698            let instrument = match parse_derive_instrument_any(&definition, ts_init) {
1699                Ok(Some(instrument)) => instrument,
1700                Ok(None) => {
1701                    log::warn!(
1702                        "Derive instrument {instrument_id} resolved to an unsupported type ({:?})",
1703                        definition.instrument_type,
1704                    );
1705                    return Ok(());
1706                }
1707                Err(e) => {
1708                    log::error!("Failed to parse Derive instrument {instrument_id}: {e}");
1709                    return Ok(());
1710                }
1711            };
1712
1713            cache_instrument(&instruments_cache, &instrument);
1714
1715            let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1716                request_id,
1717                client_id,
1718                instrument.id(),
1719                instrument,
1720                start_nanos,
1721                end_nanos,
1722                clock.get_time_ns(),
1723                params,
1724            )));
1725
1726            if let Err(e) = sender.send(DataEvent::Response(response)) {
1727                log::error!("Failed to send Derive instrument response: {e}");
1728            }
1729            Ok(())
1730        });
1731
1732        Ok(())
1733    }
1734}
1735
1736fn cache_instrument(
1737    instruments: &Arc<AtomicMap<InstrumentId, InstrumentAny>>,
1738    instrument: &InstrumentAny,
1739) {
1740    instruments.insert(instrument.id(), instrument.clone());
1741}
1742
1743fn process_ticker_quote(
1744    msg: &DeriveTickerMsg,
1745    price_precision: u8,
1746    size_precision: u8,
1747    ts_init: UnixNanos,
1748    quote_cache: &mut QuoteCache,
1749) -> anyhow::Result<Option<QuoteTick>> {
1750    let quote = parse_ticker_quote(msg, price_precision, size_precision, ts_init)?;
1751    let (bid_price, bid_size) = quote_side(quote.bid_price, quote.bid_size);
1752    let (ask_price, ask_size) = quote_side(quote.ask_price, quote.ask_size);
1753
1754    match quote_cache.process(
1755        quote.instrument_id,
1756        bid_price,
1757        ask_price,
1758        bid_size,
1759        ask_size,
1760        quote.ts_event,
1761        quote.ts_init,
1762    ) {
1763        Ok(quote) => Ok(Some(quote)),
1764        Err(e) => {
1765            log::debug!(
1766                "Skipping partial Derive ticker quote for {}: {e}",
1767                msg.data.instrument_name(),
1768            );
1769            Ok(None)
1770        }
1771    }
1772}
1773
1774fn quote_side(price: Price, size: Quantity) -> (Option<Price>, Option<Quantity>) {
1775    if price.is_zero() || size.is_zero() {
1776        (None, None)
1777    } else {
1778        (Some(price), Some(size))
1779    }
1780}
1781
1782fn channel_is_active(
1783    channels: &AtomicMap<InstrumentId, String>,
1784    instrument_id: InstrumentId,
1785    channel: &str,
1786) -> bool {
1787    channels
1788        .get_cloned(&instrument_id)
1789        .is_some_and(|active_channel| active_channel == channel)
1790}
1791
1792fn remove_channel_if_matches(
1793    channels: &AtomicMap<InstrumentId, String>,
1794    instrument_id: InstrumentId,
1795    channel: &str,
1796) {
1797    if channel_is_active(channels, instrument_id, channel) {
1798        channels.remove(&instrument_id);
1799    }
1800}
1801
1802#[allow(clippy::too_many_arguments)]
1803fn rollback_ticker_subscription(
1804    channels: &AtomicMap<InstrumentId, String>,
1805    quote_subs: &AtomicSet<InstrumentId>,
1806    mark_subs: &AtomicSet<InstrumentId>,
1807    index_subs: &AtomicSet<InstrumentId>,
1808    funding_subs: &AtomicSet<InstrumentId>,
1809    greeks_subs: &AtomicSet<InstrumentId>,
1810    instrument_id: InstrumentId,
1811    channel: &str,
1812) {
1813    remove_channel_if_matches(channels, instrument_id, channel);
1814    quote_subs.remove(&instrument_id);
1815    mark_subs.remove(&instrument_id);
1816    index_subs.remove(&instrument_id);
1817    funding_subs.remove(&instrument_id);
1818    greeks_subs.remove(&instrument_id);
1819}
1820
1821fn orderbook_channel_parts(channel: &str) -> anyhow::Result<(String, String, String)> {
1822    let rest = channel
1823        .strip_prefix("orderbook.")
1824        .ok_or_else(|| anyhow::anyhow!("invalid Derive orderbook channel `{channel}`"))?;
1825    let mut parts = rest.rsplitn(3, '.');
1826    let depth = parts
1827        .next()
1828        .filter(|value| !value.is_empty())
1829        .ok_or_else(|| anyhow::anyhow!("invalid Derive orderbook channel `{channel}`"))?;
1830    let group = parts
1831        .next()
1832        .filter(|value| !value.is_empty())
1833        .ok_or_else(|| anyhow::anyhow!("invalid Derive orderbook channel `{channel}`"))?;
1834    let instrument_name = parts
1835        .next()
1836        .filter(|value| !value.is_empty())
1837        .ok_or_else(|| anyhow::anyhow!("invalid Derive orderbook channel `{channel}`"))?;
1838
1839    Ok((
1840        instrument_name.to_string(),
1841        group.to_string(),
1842        depth.to_string(),
1843    ))
1844}
1845
1846fn ticker_channel_parts(channel: &str) -> anyhow::Result<(String, String)> {
1847    let rest = channel
1848        .strip_prefix("ticker_slim.")
1849        .or_else(|| channel.strip_prefix("ticker."))
1850        .ok_or_else(|| anyhow::anyhow!("invalid Derive ticker channel `{channel}`"))?;
1851    let (instrument_name, interval) = rest
1852        .rsplit_once('.')
1853        .ok_or_else(|| anyhow::anyhow!("invalid Derive ticker channel `{channel}`"))?;
1854    anyhow::ensure!(
1855        !instrument_name.is_empty() && !interval.is_empty(),
1856        "invalid Derive ticker channel `{channel}`"
1857    );
1858
1859    Ok((instrument_name.to_string(), interval.to_string()))
1860}
1861
1862fn orderbook_group(params: &Option<Params>) -> anyhow::Result<String> {
1863    let group = params
1864        .as_ref()
1865        .and_then(|p| {
1866            p.get_str("group")
1867                .map(ToOwned::to_owned)
1868                .or_else(|| p.get_u64("group").map(|value| value.to_string()))
1869        })
1870        .unwrap_or_else(|| DEFAULT_ORDERBOOK_GROUP.to_string());
1871
1872    DeriveOrderbookGroup::from_str(&group)
1873        .with_context(|| format!("invalid Derive orderbook group `{group}`"))?;
1874    Ok(group)
1875}
1876
1877fn orderbook_depth(depth: Option<usize>, params: &Option<Params>) -> anyhow::Result<String> {
1878    let depth = depth
1879        .map(|value| value.to_string())
1880        .or_else(|| {
1881            params.as_ref().and_then(|p| {
1882                p.get_str("depth")
1883                    .map(ToOwned::to_owned)
1884                    .or_else(|| p.get_u64("depth").map(|value| value.to_string()))
1885            })
1886        })
1887        .unwrap_or_else(|| DEFAULT_ORDERBOOK_DEPTH.to_string());
1888
1889    DeriveOrderbookDepth::from_str(&depth)
1890        .with_context(|| format!("invalid Derive orderbook depth `{depth}`"))?;
1891    Ok(depth)
1892}
1893
1894fn ticker_interval(params: &Option<Params>) -> anyhow::Result<String> {
1895    let interval = params
1896        .as_ref()
1897        .and_then(|p| {
1898            p.get_str("interval")
1899                .map(ToOwned::to_owned)
1900                .or_else(|| p.get_u64("interval").map(|value| value.to_string()))
1901        })
1902        .unwrap_or_else(|| DEFAULT_TICKER_INTERVAL.to_string());
1903
1904    DeriveTickerInterval::from_str(&interval)
1905        .with_context(|| format!("invalid Derive ticker interval `{interval}`"))?;
1906    Ok(interval)
1907}
1908
1909fn trade_channel(instrument: &InstrumentAny) -> anyhow::Result<String> {
1910    let instrument_type = derive_instrument_type(instrument)?.to_string();
1911    let instrument_id = instrument.id();
1912    let currency = currency_from_instrument_id(&instrument_id)?;
1913    Ok(trades_channel(&instrument_type, currency))
1914}
1915
1916fn derive_instrument_type(instrument: &InstrumentAny) -> anyhow::Result<DeriveInstrumentType> {
1917    match instrument {
1918        InstrumentAny::CryptoPerpetual(_) => Ok(DeriveInstrumentType::Perp),
1919        InstrumentAny::CryptoOption(_) => Ok(DeriveInstrumentType::Option),
1920        InstrumentAny::CurrencyPair(_) => Ok(DeriveInstrumentType::Erc20),
1921        other => anyhow::bail!("unsupported Derive instrument type for trades: {other:?}"),
1922    }
1923}
1924
1925fn currency_from_instrument_id(instrument_id: &InstrumentId) -> anyhow::Result<&str> {
1926    anyhow::ensure!(
1927        instrument_id.venue == *DERIVE_VENUE,
1928        "instrument ID `{instrument_id}` is not for venue {}",
1929        DERIVE_VENUE.as_str(),
1930    );
1931
1932    instrument_id
1933        .symbol
1934        .as_str()
1935        .split_once('-')
1936        .and_then(|(currency, _)| (!currency.is_empty()).then_some(currency))
1937        .ok_or_else(|| anyhow::anyhow!("cannot derive currency from {instrument_id}"))
1938}
1939
1940fn active_trade_channel_count(
1941    instruments: &AtomicMap<InstrumentId, InstrumentAny>,
1942    active_trade_subs: &AtomicSet<InstrumentId>,
1943    channel: &str,
1944) -> usize {
1945    active_trade_subs
1946        .load()
1947        .iter()
1948        .filter(|instrument_id| {
1949            instruments
1950                .get_cloned(instrument_id)
1951                .and_then(|instrument| trade_channel(&instrument).ok())
1952                .is_some_and(|active_channel| active_channel == channel)
1953        })
1954        .count()
1955}
1956
1957// Caps the rendered JSON at ~512 bytes for log grep-ability and backs the
1958// slice off to a UTF-8 char boundary so a multi-byte codepoint near the cap
1959// can never produce a panicking slice.
1960fn truncated_payload_snippet(raw: &str) -> String {
1961    const MAX_LEN: usize = 512;
1962    if raw.len() <= MAX_LEN {
1963        return raw.to_string();
1964    }
1965    let mut end = MAX_LEN;
1966    while end > 0 && !raw.is_char_boundary(end) {
1967        end -= 1;
1968    }
1969    format!("{}...(truncated)", &raw[..end])
1970}
1971
1972#[cfg(test)]
1973mod tests {
1974    use std::{path::PathBuf, time::Duration};
1975
1976    use nautilus_common::{live::runner::replace_data_event_sender, testing::wait_until_async};
1977    use nautilus_core::UnixNanos;
1978    use nautilus_model::{
1979        identifiers::InstrumentId,
1980        types::{Price, Quantity},
1981    };
1982    use rstest::rstest;
1983    use serde_json::{Value, json};
1984
1985    use super::*;
1986    use crate::{
1987        common::{
1988            consts::DERIVE_CLIENT_ID, enums::DeriveEnvironment, parse::parse_derive_instrument_any,
1989        },
1990        http::models::DeriveInstrument,
1991        websocket::{DeriveWsFrame, WsSubscriptionPayload},
1992    };
1993
1994    fn data_path() -> PathBuf {
1995        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data")
1996    }
1997
1998    fn load_json(filename: &str) -> Value {
1999        let content = std::fs::read_to_string(data_path().join(filename))
2000            .unwrap_or_else(|_| panic!("failed to read {filename}"));
2001        serde_json::from_str(&content).expect("invalid json")
2002    }
2003
2004    #[rstest]
2005    fn test_truncated_payload_snippet_short_payload_is_unchanged() {
2006        let short = json!({"ok": true}).to_string();
2007        assert_eq!(truncated_payload_snippet(&short), r#"{"ok":true}"#);
2008    }
2009
2010    #[rstest]
2011    fn test_truncated_payload_snippet_truncates_long_ascii_payload() {
2012        let big = json!({"msg": "x".repeat(1024)}).to_string();
2013        let snippet = truncated_payload_snippet(&big);
2014        assert!(snippet.ends_with("...(truncated)"));
2015        // Body before the suffix capped near the 512-byte target.
2016        assert!(snippet.len() <= 512 + "...(truncated)".len());
2017    }
2018
2019    #[rstest]
2020    fn test_truncated_payload_snippet_handles_multibyte_at_boundary() {
2021        // Construct a payload where MAX_LEN (512) lands *inside* a UTF-8
2022        // codepoint. `{"a":"x` is 7 bytes (odd-aligned), each 'e' with acute
2023        // accent (U+00E9) is 2 bytes, and 7 + 252*2 = 511, so byte 512 falls
2024        // inside the 253rd accented char. Under the old fixed-byte slice
2025        // `&raw[..512]` would panic with "byte index 512 is not a char
2026        // boundary"; with the backoff it must return a valid `String`
2027        // strictly shorter than 512 bytes for the body.
2028        let value: String = format!("x{}", "\u{00E9}".repeat(1024));
2029        let big = json!({"a": value});
2030        let raw = big.to_string();
2031        assert!(
2032            !raw.is_char_boundary(512),
2033            "test premise: 512 must be mid-codepoint",
2034        );
2035
2036        let snippet = truncated_payload_snippet(&raw);
2037        assert!(snippet.ends_with("...(truncated)"));
2038        let body_len = snippet.len() - "...(truncated)".len();
2039        assert!(body_len <= 512);
2040    }
2041
2042    fn subscription_payload(channel: &str, data: &Value) -> WsSubscriptionPayload {
2043        let frame = json!({
2044            "jsonrpc": "2.0",
2045            "method": "subscription",
2046            "params": {
2047                "channel": channel,
2048                "data": data
2049            }
2050        });
2051
2052        match DeriveWsFrame::parse(&frame.to_string()).unwrap() {
2053            DeriveWsFrame::Subscription(payload) => payload,
2054            other => panic!("expected subscription frame, was {other:?}"),
2055        }
2056    }
2057
2058    fn make_ctx(
2059        instrument: Option<InstrumentAny>,
2060    ) -> (
2061        WsMessageContext,
2062        tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
2063    ) {
2064        let (data_sender, data_rx) = tokio::sync::mpsc::unbounded_channel();
2065        let instruments = Arc::new(AtomicMap::new());
2066        if let Some(instrument) = instrument {
2067            cache_instrument(&instruments, &instrument);
2068        }
2069
2070        (
2071            WsMessageContext {
2072                clock: get_atomic_clock_realtime(),
2073                data_sender,
2074                instruments,
2075                active_book_delta_channels: Arc::new(AtomicMap::new()),
2076                active_book_depth10_channels: Arc::new(AtomicMap::new()),
2077                active_ticker_channels: Arc::new(AtomicMap::new()),
2078                active_quote_subs: Arc::new(AtomicSet::new()),
2079                active_trade_subs: Arc::new(AtomicSet::new()),
2080                active_mark_subs: Arc::new(AtomicSet::new()),
2081                active_index_subs: Arc::new(AtomicSet::new()),
2082                active_funding_subs: Arc::new(AtomicSet::new()),
2083                active_greeks_subs: Arc::new(AtomicSet::new()),
2084                quote_cache: QuoteCache::new(),
2085            },
2086            data_rx,
2087        )
2088    }
2089
2090    fn perp_instrument() -> InstrumentAny {
2091        parse_derive_instrument_any(&perp_definition("ETH-PERP", "ETH"), UnixNanos::from(1))
2092            .unwrap()
2093            .unwrap()
2094    }
2095
2096    fn btc_perp_instrument() -> InstrumentAny {
2097        parse_derive_instrument_any(&perp_definition("BTC-PERP", "BTC"), UnixNanos::from(1))
2098            .unwrap()
2099            .unwrap()
2100    }
2101
2102    fn perp_definition(name: &str, currency: &str) -> DeriveInstrument {
2103        let mut value = load_json("perps/instrument_eth.json");
2104        value["base_currency"] = json!(currency);
2105        value["instrument_name"] = json!(name);
2106        value["perp_details"]["index"] = json!(format!("{currency}-USD"));
2107
2108        serde_json::from_value(value).unwrap()
2109    }
2110
2111    fn option_instrument() -> InstrumentAny {
2112        let definition: DeriveInstrument =
2113            serde_json::from_value(load_json("options/instrument_eth.json")).unwrap();
2114        parse_derive_instrument_any(&definition, UnixNanos::from(1))
2115            .unwrap()
2116            .unwrap()
2117    }
2118
2119    fn spot_instrument() -> InstrumentAny {
2120        let definition: DeriveInstrument =
2121            serde_json::from_value(load_json("spot/instrument_eth.json")).unwrap();
2122        parse_derive_instrument_any(&definition, UnixNanos::from(1))
2123            .unwrap()
2124            .unwrap()
2125    }
2126
2127    fn ticker_json(timestamp: i64) -> Value {
2128        let mut value = load_json("perps/ws_ticker_eth.json");
2129        value["timestamp"] = json!(timestamp);
2130        value
2131    }
2132
2133    fn option_ticker_json(timestamp: i64) -> Value {
2134        let mut value = load_json("options/http_ticker_eth_snapshot.json");
2135        value["timestamp"] = json!(timestamp);
2136        value
2137    }
2138
2139    fn spot_ticker_slim_json() -> Value {
2140        load_json("spot/ws_ticker_slim_eth.json")
2141    }
2142
2143    fn orderbook_json() -> Value {
2144        load_json("perps/ws_orderbook_eth.json")
2145    }
2146
2147    fn trade_json(instrument_name: &str, trade_id: &str) -> Value {
2148        let mut value = load_json("perps/ws_trade_eth.json");
2149        value["instrument_name"] = json!(instrument_name);
2150        value["trade_id"] = json!(trade_id);
2151        value
2152    }
2153
2154    #[rstest]
2155    fn test_handle_ticker_subscription_emits_quote_with_instrument_precision() {
2156        let instrument = perp_instrument();
2157        let instrument_id = instrument.id();
2158        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2159        ctx.active_ticker_channels
2160            .insert(instrument_id, "ticker_slim.ETH-PERP.1000".to_string());
2161        ctx.active_quote_subs.insert(instrument_id);
2162        let payload = subscription_payload(
2163            "ticker_slim.ETH-PERP.1000",
2164            &json!({
2165                "timestamp": 1_700_000_000_010_i64,
2166                "instrument_ticker": ticker_json(1_700_000_000_000)
2167            }),
2168        );
2169
2170        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2171
2172        match rx.try_recv().unwrap() {
2173            DataEvent::Data(Data::Quote(quote)) => {
2174                assert_eq!(quote.instrument_id, instrument_id);
2175                assert_eq!(quote.bid_price, Price::from("3500.00"));
2176                assert_eq!(quote.ask_price, Price::from("3501.00"));
2177                assert_eq!(quote.bid_size, Quantity::from("1.000"));
2178                assert_eq!(quote.ask_size, Quantity::from("2.000"));
2179                assert_eq!(quote.bid_price.precision, 2);
2180                assert_eq!(quote.bid_size.precision, 3);
2181            }
2182            other => panic!("expected quote data event, was {other:?}"),
2183        }
2184    }
2185
2186    #[rstest]
2187    fn test_handle_ticker_partial_quote_without_cache_emits_no_quote() {
2188        let instrument = spot_instrument();
2189        let instrument_id = instrument.id();
2190        let channel = "ticker_slim.ETH-USDC.1000";
2191        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2192        install_ticker(&ctx, instrument_id, channel);
2193        ctx.active_quote_subs.insert(instrument_id);
2194        let payload = subscription_payload(channel, &spot_ticker_slim_json());
2195
2196        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2197
2198        assert!(rx.try_recv().is_err());
2199    }
2200
2201    #[rstest]
2202    fn test_handle_ticker_partial_quote_uses_cached_side() {
2203        let instrument = spot_instrument();
2204        let instrument_id = instrument.id();
2205        let channel = "ticker_slim.ETH-USDC.1000";
2206        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2207        let cached_quote = QuoteTick::new(
2208            instrument_id,
2209            Price::from("0.1"),
2210            Price::from("0.3"),
2211            Quantity::from("10.00"),
2212            Quantity::from("20.00"),
2213            UnixNanos::from(1),
2214            UnixNanos::from(1),
2215        );
2216        ctx.quote_cache.insert(instrument_id, cached_quote);
2217        install_ticker(&ctx, instrument_id, channel);
2218        ctx.active_quote_subs.insert(instrument_id);
2219        let payload = subscription_payload(channel, &spot_ticker_slim_json());
2220
2221        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2222
2223        match rx.try_recv().unwrap() {
2224            DataEvent::Data(Data::Quote(quote)) => {
2225                assert_eq!(quote.instrument_id, instrument_id);
2226                assert_eq!(quote.bid_price, Price::from("0.2"));
2227                assert_eq!(quote.ask_price, Price::from("0.3"));
2228                assert_eq!(quote.bid_size, Quantity::from("45.00"));
2229                assert_eq!(quote.ask_size, Quantity::from("20.00"));
2230            }
2231            other => panic!("expected quote data event, was {other:?}"),
2232        }
2233    }
2234
2235    #[rstest]
2236    fn test_handle_reconnected_clears_quote_cache() {
2237        let instrument = spot_instrument();
2238        let instrument_id = instrument.id();
2239        let channel = "ticker_slim.ETH-USDC.1000";
2240        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2241        let cached_quote = QuoteTick::new(
2242            instrument_id,
2243            Price::from("0.1"),
2244            Price::from("0.3"),
2245            Quantity::from("10.00"),
2246            Quantity::from("20.00"),
2247            UnixNanos::from(1),
2248            UnixNanos::from(1),
2249        );
2250        ctx.quote_cache.insert(instrument_id, cached_quote);
2251        install_ticker(&ctx, instrument_id, channel);
2252        ctx.active_quote_subs.insert(instrument_id);
2253
2254        DeriveDataClient::handle_ws_message(DeriveWsMessage::Reconnected, &mut ctx);
2255        let payload = subscription_payload(channel, &spot_ticker_slim_json());
2256        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2257
2258        assert!(rx.try_recv().is_err());
2259    }
2260
2261    #[rstest]
2262    fn test_handle_orderbook_subscription_emits_snapshot_deltas() {
2263        let instrument = perp_instrument();
2264        let instrument_id = instrument.id();
2265        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2266        ctx.active_book_delta_channels
2267            .insert(instrument_id, "orderbook.ETH-PERP.1.10".to_string());
2268        let payload = subscription_payload("orderbook.ETH-PERP.1.10", &orderbook_json());
2269
2270        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2271
2272        match rx.try_recv().unwrap() {
2273            DataEvent::Data(Data::Deltas(deltas)) => {
2274                assert_eq!(deltas.instrument_id, instrument_id);
2275                assert_eq!(deltas.deltas.len(), 3);
2276                assert_eq!(deltas.deltas[1].order.price, Price::from("3500.00"));
2277                assert_eq!(deltas.deltas[1].order.size, Quantity::from("1.000"));
2278                assert_eq!(deltas.deltas[2].order.price, Price::from("3501.00"));
2279                assert_eq!(deltas.deltas[2].order.size, Quantity::from("2.000"));
2280            }
2281            other => panic!("expected deltas data event, was {other:?}"),
2282        }
2283    }
2284
2285    #[rstest]
2286    fn test_handle_orderbook_subscription_emits_for_depth10_subscription() {
2287        let instrument = perp_instrument();
2288        let instrument_id = instrument.id();
2289        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2290        ctx.active_book_depth10_channels
2291            .insert(instrument_id, "orderbook.ETH-PERP.1.10".to_string());
2292        let payload = subscription_payload("orderbook.ETH-PERP.1.10", &orderbook_json());
2293
2294        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2295
2296        match rx.try_recv().unwrap() {
2297            DataEvent::Data(Data::Depth10(depth)) => {
2298                assert_eq!(depth.instrument_id, instrument_id);
2299                assert_eq!(depth.bids[0].price, Price::from("3500.00"));
2300                assert_eq!(depth.bids[0].size, Quantity::from("1.000"));
2301                assert_eq!(depth.asks[0].price, Price::from("3501.00"));
2302                assert_eq!(depth.asks[0].size, Quantity::from("2.000"));
2303            }
2304            other => panic!("expected depth10 data event, was {other:?}"),
2305        }
2306    }
2307
2308    #[rstest]
2309    fn test_orderbook_frame_ignored_for_inactive_channel() {
2310        let instrument = perp_instrument();
2311        let instrument_id = instrument.id();
2312        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2313        ctx.active_book_delta_channels
2314            .insert(instrument_id, "orderbook.ETH-PERP.1.20".to_string());
2315        let payload = subscription_payload("orderbook.ETH-PERP.1.10", &orderbook_json());
2316
2317        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2318
2319        assert!(rx.try_recv().is_err());
2320    }
2321
2322    #[rstest]
2323    fn test_handle_trades_subscription_filters_and_emits_active_instrument() {
2324        let instrument = perp_instrument();
2325        let other = btc_perp_instrument();
2326        let instrument_id = instrument.id();
2327        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2328        cache_instrument(&ctx.instruments, &other);
2329        ctx.active_trade_subs.insert(instrument_id);
2330        let payload = subscription_payload(
2331            "trades.perp.ETH",
2332            &json!([
2333                trade_json("ETH-PERP", "trade-1"),
2334                trade_json("BTC-PERP", "trade-2")
2335            ]),
2336        );
2337
2338        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2339
2340        match rx.try_recv().unwrap() {
2341            DataEvent::Data(Data::Trade(trade)) => {
2342                assert_eq!(trade.instrument_id, instrument_id);
2343                assert_eq!(trade.trade_id.to_string(), "trade-1");
2344                assert_eq!(trade.price, Price::from("3500.00"));
2345                assert_eq!(trade.size, Quantity::from("1.000"));
2346            }
2347            other => panic!("expected trade data event, was {other:?}"),
2348        }
2349        assert!(rx.try_recv().is_err());
2350    }
2351
2352    #[rstest]
2353    fn test_handle_subscription_without_cached_instrument_emits_no_event() {
2354        let (mut ctx, mut rx) = make_ctx(None);
2355        let instrument_id = InstrumentId::from("ETH-PERP.DERIVE");
2356        ctx.active_ticker_channels
2357            .insert(instrument_id, "ticker_slim.ETH-PERP.1000".to_string());
2358        ctx.active_quote_subs.insert(instrument_id);
2359        let payload =
2360            subscription_payload("ticker_slim.ETH-PERP.1000", &ticker_json(1_700_000_000_000));
2361
2362        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2363
2364        assert!(rx.try_recv().is_err());
2365    }
2366
2367    #[rstest]
2368    fn test_ticker_frame_ignored_without_quote_subscription() {
2369        let instrument = perp_instrument();
2370        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2371        let payload =
2372            subscription_payload("ticker_slim.ETH-PERP.1000", &ticker_json(1_700_000_000_000));
2373
2374        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2375
2376        assert!(rx.try_recv().is_err());
2377    }
2378
2379    #[rstest]
2380    fn test_ticker_frame_ignored_for_inactive_channel() {
2381        let instrument = perp_instrument();
2382        let instrument_id = instrument.id();
2383        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2384        ctx.active_ticker_channels
2385            .insert(instrument_id, "ticker_slim.ETH-PERP.100".to_string());
2386        ctx.active_quote_subs.insert(instrument_id);
2387        let payload =
2388            subscription_payload("ticker_slim.ETH-PERP.1000", &ticker_json(1_700_000_000_000));
2389
2390        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2391
2392        assert!(rx.try_recv().is_err());
2393    }
2394
2395    #[rstest]
2396    fn test_trade_channel_uses_instrument_type_and_currency() {
2397        let instrument = perp_instrument();
2398
2399        assert_eq!(trade_channel(&instrument).unwrap(), "trades.perp.ETH");
2400    }
2401
2402    #[rstest]
2403    fn test_trade_channel_uses_erc20_for_spot() {
2404        let instrument = spot_instrument();
2405
2406        assert_eq!(
2407            derive_instrument_type(&instrument).unwrap(),
2408            DeriveInstrumentType::Erc20
2409        );
2410        assert_eq!(trade_channel(&instrument).unwrap(), "trades.erc20.ETH");
2411    }
2412
2413    #[rstest]
2414    fn test_param_defaults_match_derive_public_channels() {
2415        assert_eq!(orderbook_group(&None).unwrap(), DEFAULT_ORDERBOOK_GROUP);
2416        assert_eq!(
2417            orderbook_depth(None, &None).unwrap(),
2418            DEFAULT_ORDERBOOK_DEPTH
2419        );
2420        assert_eq!(ticker_interval(&None).unwrap(), DEFAULT_TICKER_INTERVAL);
2421    }
2422
2423    #[rstest]
2424    fn test_orderbook_channel_parts_splits_from_right() {
2425        assert_eq!(
2426            orderbook_channel_parts("orderbook.ETH.TEST-PERP.10.100").unwrap(),
2427            (
2428                "ETH.TEST-PERP".to_string(),
2429                "10".to_string(),
2430                "100".to_string()
2431            )
2432        );
2433    }
2434
2435    #[rstest]
2436    fn test_ticker_channel_parts_splits_from_right() {
2437        assert_eq!(
2438            ticker_channel_parts("ticker_slim.ETH.TEST-PERP.1000").unwrap(),
2439            ("ETH.TEST-PERP".to_string(), "1000".to_string())
2440        );
2441    }
2442
2443    #[rstest]
2444    fn test_ticker_channel_parts_accepts_legacy_ticker_channel() {
2445        assert_eq!(
2446            ticker_channel_parts("ticker.ETH.TEST-PERP.1000").unwrap(),
2447            ("ETH.TEST-PERP".to_string(), "1000".to_string())
2448        );
2449    }
2450
2451    fn perp_ticker_payload(instrument_id: InstrumentId) -> WsSubscriptionPayload {
2452        let channel = "ticker_slim.ETH-PERP.1000";
2453        let payload = subscription_payload(
2454            channel,
2455            &json!({
2456                "timestamp": 1_700_000_000_010_i64,
2457                "instrument_ticker": ticker_json(1_700_000_000_000)
2458            }),
2459        );
2460        assert_eq!(payload.channel.as_str(), channel);
2461        let _ = instrument_id;
2462        payload
2463    }
2464
2465    fn install_ticker(ctx: &WsMessageContext, instrument_id: InstrumentId, channel: &str) {
2466        ctx.active_ticker_channels
2467            .insert(instrument_id, channel.to_string());
2468    }
2469
2470    #[rstest]
2471    fn test_ticker_emits_mark_price_when_mark_subscribed() {
2472        let instrument = perp_instrument();
2473        let instrument_id = instrument.id();
2474        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2475        install_ticker(&ctx, instrument_id, "ticker_slim.ETH-PERP.1000");
2476        ctx.active_mark_subs.insert(instrument_id);
2477
2478        DeriveDataClient::handle_ws_message(
2479            DeriveWsMessage::Subscription(perp_ticker_payload(instrument_id)),
2480            &mut ctx,
2481        );
2482
2483        match rx.try_recv().unwrap() {
2484            DataEvent::Data(Data::MarkPriceUpdate(mark)) => {
2485                assert_eq!(mark.instrument_id, instrument_id);
2486                assert_eq!(mark.value, Price::from("3500.50"));
2487            }
2488            other => panic!("expected MarkPriceUpdate, was {other:?}"),
2489        }
2490        assert!(rx.try_recv().is_err());
2491    }
2492
2493    #[rstest]
2494    fn test_ticker_emits_index_price_when_index_subscribed() {
2495        let instrument = perp_instrument();
2496        let instrument_id = instrument.id();
2497        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2498        install_ticker(&ctx, instrument_id, "ticker_slim.ETH-PERP.1000");
2499        ctx.active_index_subs.insert(instrument_id);
2500
2501        DeriveDataClient::handle_ws_message(
2502            DeriveWsMessage::Subscription(perp_ticker_payload(instrument_id)),
2503            &mut ctx,
2504        );
2505
2506        match rx.try_recv().unwrap() {
2507            DataEvent::Data(Data::IndexPriceUpdate(index)) => {
2508                assert_eq!(index.instrument_id, instrument_id);
2509                assert_eq!(index.value, Price::from("3500.00"));
2510            }
2511            other => panic!("expected IndexPriceUpdate, was {other:?}"),
2512        }
2513        assert!(rx.try_recv().is_err());
2514    }
2515
2516    #[rstest]
2517    fn test_ticker_emits_funding_rate_for_perp_when_subscribed() {
2518        let instrument = perp_instrument();
2519        let instrument_id = instrument.id();
2520        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2521        install_ticker(&ctx, instrument_id, "ticker_slim.ETH-PERP.1000");
2522        ctx.active_funding_subs.insert(instrument_id);
2523
2524        DeriveDataClient::handle_ws_message(
2525            DeriveWsMessage::Subscription(perp_ticker_payload(instrument_id)),
2526            &mut ctx,
2527        );
2528
2529        match rx.try_recv().unwrap() {
2530            DataEvent::FundingRate(update) => {
2531                assert_eq!(update.instrument_id, instrument_id);
2532                assert_eq!(update.rate, "0.0002".parse().unwrap());
2533            }
2534            other => panic!("expected FundingRateUpdate, was {other:?}"),
2535        }
2536        assert!(rx.try_recv().is_err());
2537    }
2538
2539    #[rstest]
2540    fn test_ticker_skips_funding_rate_when_not_perp() {
2541        let instrument = option_instrument();
2542        let instrument_id = instrument.id();
2543        let channel = format!("ticker_slim.{}.1000", instrument_id.symbol.as_str());
2544        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2545        install_ticker(&ctx, instrument_id, &channel);
2546        ctx.active_funding_subs.insert(instrument_id);
2547
2548        let mut option_data = option_ticker_json(1_700_000_000_000);
2549        option_data["instrument_name"] = json!(instrument_id.symbol.as_str());
2550        let payload = subscription_payload(
2551            &channel,
2552            &json!({
2553                "timestamp": 1_700_000_000_010_i64,
2554                "instrument_ticker": option_data
2555            }),
2556        );
2557
2558        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2559
2560        assert!(rx.try_recv().is_err());
2561    }
2562
2563    #[rstest]
2564    fn test_ticker_emits_option_greeks_when_subscribed() {
2565        let instrument = option_instrument();
2566        let instrument_id = instrument.id();
2567        let channel = format!("ticker_slim.{}.1000", instrument_id.symbol.as_str());
2568        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2569        install_ticker(&ctx, instrument_id, &channel);
2570        ctx.active_greeks_subs.insert(instrument_id);
2571
2572        let mut option_data = option_ticker_json(1_700_000_000_000);
2573        option_data["instrument_name"] = json!(instrument_id.symbol.as_str());
2574        let payload = subscription_payload(
2575            &channel,
2576            &json!({
2577                "timestamp": 1_700_000_000_010_i64,
2578                "instrument_ticker": option_data
2579            }),
2580        );
2581
2582        DeriveDataClient::handle_ws_message(DeriveWsMessage::Subscription(payload), &mut ctx);
2583
2584        match rx.try_recv().unwrap() {
2585            DataEvent::OptionGreeks(greeks) => {
2586                assert_eq!(greeks.instrument_id, instrument_id);
2587                assert!((greeks.greeks.delta - 0.55).abs() < 1e-9);
2588                assert!((greeks.greeks.gamma - 0.0008).abs() < 1e-9);
2589                assert!((greeks.greeks.vega - 4.5).abs() < 1e-9);
2590                assert!((greeks.greeks.theta + 2.1).abs() < 1e-9);
2591                assert!((greeks.greeks.rho - 1.2).abs() < 1e-9);
2592                assert_eq!(greeks.mark_iv, Some(0.60));
2593                assert_eq!(greeks.bid_iv, Some(0.58));
2594                assert_eq!(greeks.ask_iv, Some(0.62));
2595                assert_eq!(greeks.underlying_price, Some(3505.0));
2596                assert_eq!(greeks.open_interest, Some(1000.0));
2597            }
2598            other => panic!("expected OptionGreeks, was {other:?}"),
2599        }
2600        assert!(rx.try_recv().is_err());
2601    }
2602
2603    #[rstest]
2604    fn test_ticker_emits_all_subscribed_feeds_in_one_frame() {
2605        let instrument = perp_instrument();
2606        let instrument_id = instrument.id();
2607        let (mut ctx, mut rx) = make_ctx(Some(instrument));
2608        install_ticker(&ctx, instrument_id, "ticker_slim.ETH-PERP.1000");
2609        ctx.active_quote_subs.insert(instrument_id);
2610        ctx.active_mark_subs.insert(instrument_id);
2611        ctx.active_index_subs.insert(instrument_id);
2612        ctx.active_funding_subs.insert(instrument_id);
2613
2614        DeriveDataClient::handle_ws_message(
2615            DeriveWsMessage::Subscription(perp_ticker_payload(instrument_id)),
2616            &mut ctx,
2617        );
2618
2619        let mut quote = None;
2620        let mut mark = None;
2621        let mut index = None;
2622        let mut funding = None;
2623
2624        while let Ok(event) = rx.try_recv() {
2625            match event {
2626                DataEvent::Data(Data::Quote(q)) => {
2627                    assert!(quote.replace(q).is_none(), "duplicate Quote emission");
2628                }
2629                DataEvent::Data(Data::MarkPriceUpdate(m)) => {
2630                    assert!(
2631                        mark.replace(m).is_none(),
2632                        "duplicate MarkPriceUpdate emission"
2633                    );
2634                }
2635                DataEvent::Data(Data::IndexPriceUpdate(i)) => {
2636                    assert!(
2637                        index.replace(i).is_none(),
2638                        "duplicate IndexPriceUpdate emission"
2639                    );
2640                }
2641                DataEvent::FundingRate(f) => {
2642                    assert!(
2643                        funding.replace(f).is_none(),
2644                        "duplicate FundingRate emission"
2645                    );
2646                }
2647                other => panic!("unexpected event: {other:?}"),
2648            }
2649        }
2650
2651        let quote = quote.expect("Quote event missing");
2652        let mark = mark.expect("MarkPriceUpdate missing");
2653        let index = index.expect("IndexPriceUpdate missing");
2654        let funding = funding.expect("FundingRateUpdate missing");
2655
2656        assert_eq!(quote.instrument_id, instrument_id);
2657        assert_eq!(quote.bid_price, Price::from("3500.00"));
2658        assert_eq!(quote.ask_price, Price::from("3501.00"));
2659        assert_eq!(mark.instrument_id, instrument_id);
2660        assert_eq!(mark.value, Price::from("3500.50"));
2661        assert_eq!(index.instrument_id, instrument_id);
2662        assert_eq!(index.value, Price::from("3500.00"));
2663        assert_eq!(funding.instrument_id, instrument_id);
2664        assert_eq!(funding.rate, "0.0002".parse().unwrap());
2665    }
2666
2667    #[rstest]
2668    fn test_reset_clears_all_subscription_state() {
2669        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2670        replace_data_event_sender(tx);
2671
2672        let config = DeriveDataClientConfig {
2673            environment: DeriveEnvironment::Mainnet,
2674            ..Default::default()
2675        };
2676        let mut client = DeriveDataClient::new(*DERIVE_CLIENT_ID, config).unwrap();
2677        let instrument = perp_instrument();
2678        let instrument_id = instrument.id();
2679        cache_instrument(&client.instruments, &instrument);
2680
2681        client
2682            .active_book_delta_channels
2683            .insert(instrument_id, "orderbook.ETH-PERP.1.10".to_string());
2684        client
2685            .active_book_depth10_channels
2686            .insert(instrument_id, "orderbook.ETH-PERP.1.10".to_string());
2687        client
2688            .active_ticker_channels
2689            .insert(instrument_id, "ticker_slim.ETH-PERP.1000".to_string());
2690        client.active_quote_subs.insert(instrument_id);
2691        client.active_trade_subs.insert(instrument_id);
2692        client
2693            .active_trade_channels
2694            .insert("trades.perp.ETH".to_string(), ());
2695        client.active_mark_subs.insert(instrument_id);
2696        client.active_index_subs.insert(instrument_id);
2697        client.active_funding_subs.insert(instrument_id);
2698        client.active_greeks_subs.insert(instrument_id);
2699
2700        client.reset().unwrap();
2701
2702        assert!(!client.instruments.contains_key(&instrument_id));
2703        assert!(
2704            !client
2705                .active_book_delta_channels
2706                .contains_key(&instrument_id)
2707        );
2708        assert!(
2709            !client
2710                .active_book_depth10_channels
2711                .contains_key(&instrument_id)
2712        );
2713        assert!(!client.active_ticker_channels.contains_key(&instrument_id));
2714        assert!(!client.active_quote_subs.contains(&instrument_id));
2715        assert!(!client.active_trade_subs.contains(&instrument_id));
2716        assert!(client.active_trade_channels.is_empty());
2717        assert!(!client.active_mark_subs.contains(&instrument_id));
2718        assert!(!client.active_index_subs.contains(&instrument_id));
2719        assert!(!client.active_funding_subs.contains(&instrument_id));
2720        assert!(!client.active_greeks_subs.contains(&instrument_id));
2721        assert!(!client.is_connected());
2722    }
2723
2724    #[tokio::test]
2725    async fn test_disconnect_clears_subscription_state() {
2726        // Regression: aborting in-flight subscribe tasks during disconnect()
2727        // skips their on-error rollback, so any `active_*` entries staged
2728        // before spawn would leak across reconnect and silently suppress
2729        // the next subscribe. disconnect() must clear the same local
2730        // subscription maps that reset() does.
2731        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2732        replace_data_event_sender(tx);
2733
2734        let config = DeriveDataClientConfig {
2735            environment: DeriveEnvironment::Mainnet,
2736            ..Default::default()
2737        };
2738        let mut client = DeriveDataClient::new(*DERIVE_CLIENT_ID, config).unwrap();
2739        let instrument = perp_instrument();
2740        let instrument_id = instrument.id();
2741        cache_instrument(&client.instruments, &instrument);
2742
2743        client
2744            .active_book_delta_channels
2745            .insert(instrument_id, "orderbook.ETH-PERP.1.10".to_string());
2746        client
2747            .active_book_depth10_channels
2748            .insert(instrument_id, "orderbook.ETH-PERP.1.10".to_string());
2749        client
2750            .active_ticker_channels
2751            .insert(instrument_id, "ticker_slim.ETH-PERP.1000".to_string());
2752        client.active_quote_subs.insert(instrument_id);
2753        client.active_trade_subs.insert(instrument_id);
2754        client
2755            .active_trade_channels
2756            .insert("trades.perp.ETH".to_string(), ());
2757        client.active_mark_subs.insert(instrument_id);
2758        client.active_index_subs.insert(instrument_id);
2759        client.active_funding_subs.insert(instrument_id);
2760        client.active_greeks_subs.insert(instrument_id);
2761        client.is_connected.store(true, Ordering::Relaxed);
2762
2763        client.disconnect().await.unwrap();
2764
2765        // Instrument cache must survive disconnect (reset() is the path
2766        // that wipes it); only the subscription maps are cleared.
2767        assert!(client.instruments.contains_key(&instrument_id));
2768        assert!(
2769            !client
2770                .active_book_delta_channels
2771                .contains_key(&instrument_id)
2772        );
2773        assert!(
2774            !client
2775                .active_book_depth10_channels
2776                .contains_key(&instrument_id)
2777        );
2778        assert!(!client.active_ticker_channels.contains_key(&instrument_id));
2779        assert!(!client.active_quote_subs.contains(&instrument_id));
2780        assert!(!client.active_trade_subs.contains(&instrument_id));
2781        assert!(client.active_trade_channels.is_empty());
2782        assert!(!client.active_mark_subs.contains(&instrument_id));
2783        assert!(!client.active_index_subs.contains(&instrument_id));
2784        assert!(!client.active_funding_subs.contains(&instrument_id));
2785        assert!(!client.active_greeks_subs.contains(&instrument_id));
2786        assert!(!client.is_connected());
2787    }
2788
2789    #[tokio::test]
2790    async fn test_spawn_task_prunes_finished_handles() {
2791        // Regression: every spawn_task call must prune finished handles
2792        // before pushing the new one, otherwise `pending_tasks` grows
2793        // unboundedly across long-running sessions.
2794        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<DataEvent>();
2795        replace_data_event_sender(tx);
2796
2797        let config = DeriveDataClientConfig {
2798            environment: DeriveEnvironment::Mainnet,
2799            ..Default::default()
2800        };
2801        let client = DeriveDataClient::new(*DERIVE_CLIENT_ID, config).unwrap();
2802
2803        // Spawn many no-op tasks, then wait until their handles are
2804        // observably finished. `spawn_task` uses the global Nautilus runtime,
2805        // so a single test-runtime yield is not a reliable completion fence
2806        // under a busy full-suite run.
2807        for _ in 0..100 {
2808            client.spawn_task("test_noop", async { Ok(()) });
2809        }
2810
2811        wait_until_async(
2812            || async {
2813                {
2814                    let tasks = client.pending_tasks.lock().expect(MUTEX_POISONED);
2815                    tasks.iter().all(JoinHandle::is_finished)
2816                }
2817            },
2818            Duration::from_secs(2),
2819        )
2820        .await;
2821
2822        // The next spawn should prune the finished handles before pushing the
2823        // new one, leaving exactly the new tracked task.
2824        client.spawn_task("test_prune", async { Ok(()) });
2825        let len = client.pending_tasks.lock().expect(MUTEX_POISONED).len();
2826        assert_eq!(len, 1, "pending_tasks should retain only the new task");
2827    }
2828}