1use 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(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 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 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 tasks.retain(|handle| !handle.is_finished());
191 tasks.push(handle);
192 }
193
194 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 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 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 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 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 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 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, ¤cy).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 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 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 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 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 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 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 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, ¤cy, 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
1957fn 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 assert!(snippet.len() <= 512 + "...(truncated)".len());
2017 }
2018
2019 #[rstest]
2020 fn test_truncated_payload_snippet_handles_multibyte_at_boundary() {
2021 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 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 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 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 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 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}