1use std::{
19 sync::{
20 Arc,
21 atomic::{AtomicBool, AtomicU64, Ordering},
22 },
23 time::Duration,
24};
25
26use ahash::AHashMap;
27use anyhow::Context;
28use dashmap::{DashMap, DashSet, mapref::entry::Entry};
29use nautilus_common::{
30 cache::InstrumentLookupError,
31 clients::DataClient,
32 live::{runner::get_data_event_sender, sender::EventSender},
33 messages::{
34 DataEvent,
35 data::{
36 BarsResponse, BookResponse, DataResponse, FundingRatesResponse, InstrumentResponse,
37 InstrumentsResponse, RequestBars, RequestBookDepth, RequestBookSnapshot,
38 RequestFundingRates, RequestInstrument, RequestInstruments, RequestQuotes,
39 RequestTrades, SubscribeBars, SubscribeBookDeltas, SubscribeBookDepth,
40 SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
41 SubscribeInstrumentStatus, SubscribeMarkPrices, SubscribeQuotes, SubscribeTrades,
42 TradesResponse, UnsubscribeBars, UnsubscribeBookDeltas, UnsubscribeBookDepth,
43 UnsubscribeFundingRates, UnsubscribeIndexPrices, UnsubscribeInstrument,
44 UnsubscribeInstrumentStatus, UnsubscribeMarkPrices, UnsubscribeQuotes,
45 UnsubscribeTrades,
46 },
47 },
48};
49use nautilus_core::{
50 AtomicMap, UnixNanos,
51 datetime::datetime_to_unix_nanos,
52 time::{AtomicTime, get_atomic_clock_realtime},
53};
54use nautilus_live::{
55 SocketControlFactory,
56 task::{TaskGroup, TaskGroupGuard, TaskJoinOutcome, TaskSlot, finish_task},
57};
58use nautilus_model::{
59 data::{Data, InstrumentStatus, TradeTick},
60 enums::{BookType, MarketStatusAction},
61 identifiers::{ClientId, InstrumentId, Venue},
62 instruments::{Instrument, InstrumentAny},
63};
64use tokio_util::sync::CancellationToken;
65
66use crate::{
67 common::{
68 consts::DISCONNECT_TIMEOUT,
69 credential::Credential,
70 enums::{LighterCandleResolution, LighterMarketStatus},
71 rate_limit::resolve_quota,
72 symbol::MarketRegistry,
73 },
74 config::LighterDataClientConfig,
75 http::{
76 client::{LighterHttpClient, LighterRawHttpClient},
77 parse::parse_l2_order_book_snapshot,
78 query::LighterOrderBookOrdersQuery,
79 },
80 websocket::{
81 DATA_STREAMS_ENDPOINT, LighterWsError,
82 client::{LighterWebSocketClient, RetainedTaskSlot, TaskRetentionGuard},
83 messages::{LighterMarketSelection, LighterWsChannel, NautilusWsMessage},
84 },
85};
86
87mod limits;
88mod market_stats;
89
90use self::{
91 limits::{clamp_book_snapshot_limit, clamp_recent_trades_limit},
92 market_stats::{
93 MarketStatsKind, MarketStatsSubscription, emit_ws_message as emit_market_stats_ws_message,
94 subscribe_channel as subscribe_market_stats_channel,
95 unsubscribe_channel as unsubscribe_market_stats_channel,
96 },
97};
98
99#[derive(Debug)]
100pub struct LighterDataClient {
101 clock: &'static AtomicTime,
102 client_id: ClientId,
103 config: LighterDataClientConfig,
104 credential: Option<Credential>,
105 http_client: LighterHttpClient,
106 ws_client: LighterWebSocketClient,
107 registry: Arc<MarketRegistry>,
108 socket_factory: SocketControlFactory,
109 is_connected: AtomicBool,
110 cancellation_token: CancellationToken,
111 tasks: TaskGroup,
112 ws_disconnect_handle: TaskSlot<Result<(), LighterWsError>>,
113 ws_handler_retained: Arc<RetainedTaskSlot>,
114 shutdown_errors: Vec<String>,
115 data_sender: EventSender<DataEvent>,
116 instruments: Arc<AtomicMap<InstrumentId, InstrumentAny>>,
117 instrument_statuses: Arc<DashMap<InstrumentId, LighterMarketStatus>>,
118 instrument_status_subscriptions: Arc<DashSet<InstrumentId>>,
119 market_stats_subscriptions: Arc<DashMap<InstrumentId, MarketStatsSubscription>>,
120 market_stats_subscription_generations: Arc<DashMap<InstrumentId, u64>>,
121 next_market_stats_subscription_generation: AtomicU64,
122}
123
124impl LighterDataClient {
125 pub fn new(client_id: ClientId, config: LighterDataClientConfig) -> anyhow::Result<Self> {
131 let clock = get_atomic_clock_realtime();
132 let data_sender = get_data_event_sender();
133 let venue = config.resolved_venue();
134 let settlement_currency = config.settlement_currency();
135 let socket_factory = SocketControlFactory::new(client_id, Some(venue));
136
137 let credential = if config.has_credentials() {
138 let private_key = config
141 .private_key
142 .as_ref()
143 .map(|value| value.expose_secret())
144 .filter(|s| !s.trim().is_empty())
145 .map(str::to_string);
146 Credential::resolve_for_deployment(
147 private_key,
148 config.account_index,
149 config.api_key_index,
150 config.deployment,
151 config.environment,
152 )
153 .context("failed to resolve Lighter data credentials")?
154 } else {
155 None
156 };
157
158 let registry = Arc::new(MarketRegistry::new_with_venue_and_settlement_currency(
159 venue,
160 settlement_currency,
161 ));
162
163 let raw_http = LighterRawHttpClient::new_with_quotas(
164 config.environment,
165 Some(config.http_url()),
166 config.http_timeout_secs,
167 config
168 .proxy_url
169 .as_ref()
170 .map(|value| value.expose_secret().to_owned()),
171 resolve_quota(config.rest_quota_per_min),
172 None,
173 )
174 .context("failed to construct Lighter raw HTTP client")?;
175
176 let http_client =
177 LighterHttpClient::from_raw_with_registry(raw_http, Arc::clone(®istry));
178
179 let ws_client = Self::create_ws_client(&config, Arc::clone(®istry), &socket_factory);
180
181 let tasks = TaskGroup::new();
182
183 Ok(Self {
184 clock,
185 client_id,
186 config,
187 credential,
188 http_client,
189 ws_client,
190 registry,
191 socket_factory,
192 is_connected: AtomicBool::new(false),
193 cancellation_token: tasks.cancellation_token(),
194 tasks,
195 ws_disconnect_handle: TaskSlot::new(),
196 ws_handler_retained: Arc::new(RetainedTaskSlot::new()),
197 shutdown_errors: Vec::new(),
198 data_sender,
199 instruments: Arc::new(AtomicMap::new()),
200 instrument_statuses: Arc::new(DashMap::new()),
201 instrument_status_subscriptions: Arc::new(DashSet::new()),
202 market_stats_subscriptions: Arc::new(DashMap::new()),
203 market_stats_subscription_generations: Arc::new(DashMap::new()),
204 next_market_stats_subscription_generation: AtomicU64::new(1),
205 })
206 }
207
208 fn venue(&self) -> Venue {
209 self.config.resolved_venue()
210 }
211
212 #[must_use]
214 pub fn has_credentials(&self) -> bool {
215 self.credential.is_some()
216 }
217
218 fn create_ws_client(
219 config: &LighterDataClientConfig,
220 registry: Arc<MarketRegistry>,
221 socket_factory: &SocketControlFactory,
222 ) -> LighterWebSocketClient {
223 let ws_client = LighterWebSocketClient::new(
224 Some(config.ws_url()),
225 config.environment,
226 registry,
227 config.transport_backend,
228 config.ws_timeout_secs,
229 Duration::from_secs(config.book_snapshot_timeout_secs),
230 config
231 .proxy_url
232 .as_ref()
233 .map(|value| value.expose_secret().to_owned()),
234 );
235
236 ws_client.with_socket_control(socket_factory.control(DATA_STREAMS_ENDPOINT))
237 }
238
239 fn take_ws_client(&mut self) -> LighterWebSocketClient {
240 std::mem::replace(
241 &mut self.ws_client,
242 Self::create_ws_client(
243 &self.config,
244 Arc::clone(&self.registry),
245 &self.socket_factory,
246 ),
247 )
248 }
249
250 fn spawn_ws_disconnect(&mut self) {
251 if self.ws_disconnect_handle.is_some() {
252 return;
253 }
254 self.ws_client.begin_shutdown();
255 let ws_client = self.take_ws_client();
256 let retained = Arc::clone(&self.ws_handler_retained);
257
258 if let Err(e) = self
259 .ws_disconnect_handle
260 .spawn(ws_client.disconnect_with_task_retention(retained))
261 {
262 log::error!("Failed to start Lighter WebSocket disconnect task: {e}");
263 }
264 }
265
266 fn spawn_task<F>(&self, fut: F)
268 where
269 F: std::future::Future<Output = ()> + Send + 'static,
270 {
271 let cancellation_token = self.cancellation_token.clone();
272
273 let future = async move {
274 tokio::select! {
275 biased;
276 () = cancellation_token.cancelled() => {}
277 () = fut => {}
278 }
279 };
280
281 if let Err(e) = self.tasks.spawn(future) {
282 log::debug!("Skipping Lighter data task after shutdown began: {e}");
283 }
284 }
285
286 fn abort_tasks(&self) {
287 self.tasks.begin_shutdown();
288 }
289
290 async fn shutdown_tasks(&mut self) -> anyhow::Result<()> {
291 self.tasks.begin_shutdown();
292 if let Err(e) = self
293 .tasks
294 .finish_shutdown(Duration::from_secs(1), DISCONNECT_TIMEOUT)
295 .await
296 {
297 self.shutdown_errors.push(format!("data tasks failed: {e}"));
298 }
299
300 Self::finish_owned_task(
301 &mut self.ws_disconnect_handle,
302 "WebSocket disconnect",
303 &mut self.shutdown_errors,
304 )
305 .await;
306
307 if let Err(e) = self.ws_handler_retained.finish().await {
308 self.shutdown_errors.push(e.to_string());
309 }
310
311 self.take_shutdown_result("Failed to terminate Lighter tasks")
312 }
313
314 fn take_shutdown_result(&mut self, context: &str) -> anyhow::Result<()> {
315 if self.shutdown_errors.is_empty() {
316 Ok(())
317 } else {
318 let errors = std::mem::take(&mut self.shutdown_errors);
319 anyhow::bail!("{context}: {}", errors.join("; "))
320 }
321 }
322
323 async fn finish_owned_task(
324 slot: &mut TaskSlot<Result<(), LighterWsError>>,
325 description: &str,
326 errors: &mut Vec<String>,
327 ) {
328 let Some(outcome) = finish_task(slot, DISCONNECT_TIMEOUT, DISCONNECT_TIMEOUT).await else {
329 return;
330 };
331
332 match outcome {
333 TaskJoinOutcome::Completed(Ok(())) | TaskJoinOutcome::Aborted => {}
334 TaskJoinOutcome::Completed(Err(e)) => {
335 errors.push(format!("{description} failed: {e}"));
336 }
337 TaskJoinOutcome::Failed(e) => {
338 errors.push(format!("{description} task failed: {e}"));
339 }
340 TaskJoinOutcome::Incomplete => {
341 errors.push(format!("{description} task did not stop after abort"));
342 }
343 }
344 }
345
346 async fn bootstrap_instruments(&self) -> anyhow::Result<Vec<InstrumentAny>> {
347 let instruments_with_status = self
348 .http_client
349 .request_instruments_with_status()
350 .await
351 .context("failed to fetch instruments during bootstrap")?;
352 let instruments: Vec<InstrumentAny> = instruments_with_status
353 .iter()
354 .map(|(instrument, _)| instrument.clone())
355 .collect();
356
357 let mut ws_cache: Vec<(i64, InstrumentAny)> = Vec::with_capacity(instruments.len());
358 self.instruments.rcu(|m| {
359 for instrument in &instruments {
360 m.insert(instrument.id(), instrument.clone());
361 }
362 });
363
364 for instrument in &instruments {
365 if let Some(market_index) = self.registry.market_index(&instrument.id()) {
366 ws_cache.push((market_index, instrument.clone()));
367 } else {
368 log::warn!(
369 "No market_index registered for instrument {} during bootstrap",
370 instrument.id(),
371 );
372 }
373 }
374
375 self.instrument_statuses.clear();
376 for (instrument, status) in &instruments_with_status {
377 cache_lighter_instrument_status(&self.instrument_statuses, instrument.id(), *status);
378 }
379
380 self.ws_client.cache_instruments(ws_cache);
381
382 log::debug!(
383 "Bootstrapped {} Lighter instruments ({} registry entries)",
384 self.instruments.len(),
385 self.registry.len(),
386 );
387 Ok(instruments)
388 }
389
390 async fn spawn_ws(&mut self) -> anyhow::Result<()> {
391 let mut ws_guard = TaskRetentionGuard::new(
395 self.ws_client.clone(),
396 Arc::clone(&self.ws_handler_retained),
397 );
398 ws_guard
399 .client_mut()
400 .connect_with_cancellation(self.cancellation_token.clone())
401 .await
402 .context("failed to connect to Lighter WebSocket")?;
403
404 if let Err(e) = ws_guard.client_mut().wait_until_active().await {
405 let ws_client = ws_guard.disarm();
406 let mut rollback_errors = Vec::new();
407
408 if let Err(e) = ws_client
409 .disconnect_with_task_retention(Arc::clone(&self.ws_handler_retained))
410 .await
411 {
412 rollback_errors.push(e.to_string());
413 }
414
415 if let Err(e) = self.ws_handler_retained.finish().await {
416 rollback_errors.push(e.to_string());
417 }
418
419 let readiness_error =
420 anyhow::Error::new(e).context("Lighter WebSocket did not reach active state");
421
422 if rollback_errors.is_empty() {
423 return Err(readiness_error);
424 }
425 return Err(readiness_error.context(format!(
426 "Lighter WebSocket readiness rollback failed: {}",
427 rollback_errors.join("; ")
428 )));
429 }
430
431 let mut ws_client = ws_guard.disarm();
432 self.ws_client.set_task_slot(ws_client.take_task_slot());
433
434 let cancellation_token = self.cancellation_token.clone();
435 let data_sender = self.data_sender.clone();
436 let market_stats_subscriptions = Arc::clone(&self.market_stats_subscriptions);
437
438 let future = async move {
439 log::debug!("Lighter WebSocket consumption loop started");
440
441 loop {
442 tokio::select! {
443 biased;
445 () = cancellation_token.cancelled() => {
446 log::debug!("Lighter WebSocket consumption loop cancelled");
447 break;
448 }
449 msg_opt = ws_client.next_event() => {
450 match msg_opt {
451 Some(NautilusWsMessage::Trades(trades)) => {
452 for trade in trades {
453 if let Err(e) = data_sender
454 .send(DataEvent::Data(Data::Trade(trade)))
455 {
456 log::error!("Failed to send trade tick: {e}");
457 }
458 }
459 }
460 Some(NautilusWsMessage::Quote(quote)) => {
461 if let Err(e) = data_sender
462 .send(DataEvent::Data(Data::Quote(quote)))
463 {
464 log::error!("Failed to send quote tick: {e}");
465 }
466 }
467 Some(NautilusWsMessage::Deltas(deltas)) => {
468 let data = Data::BookDeltas(Box::new(deltas));
469 if let Err(e) = data_sender.send(DataEvent::Data(data)) {
470 log::error!("Failed to send order book deltas: {e}");
471 }
472 }
473 Some(NautilusWsMessage::Depth(depth)) => {
474 if let Err(e) =
475 data_sender.send(DataEvent::Data(Data::BookDepth(depth)))
476 {
477 log::error!("Failed to send order book depth: {e}");
478 }
479 }
480 Some(NautilusWsMessage::Bar(bar)) => {
481 if let Err(e) = data_sender.send(DataEvent::Data(Data::Bar(bar))) {
482 log::error!("Failed to send bar: {e}");
483 }
484 }
485 Some(message @ (NautilusWsMessage::MarkPrice(_)
486 | NautilusWsMessage::IndexPrice(_)
487 | NautilusWsMessage::FundingRate(_))) =>
488 {
489 emit_market_stats_ws_message(
490 &data_sender,
491 &market_stats_subscriptions,
492 &message,
493 );
494 }
495 Some(NautilusWsMessage::Raw(value)) => {
496 log::debug!("Unhandled Lighter raw frame: {value}");
497 }
498 Some(
502 NautilusWsMessage::ExecutionReports(_)
503 | NautilusWsMessage::PositionSnapshot { .. }
504 | NautilusWsMessage::PositionUpdate { .. }
505 | NautilusWsMessage::AccountState(_)
506 | NautilusWsMessage::SendTxAck { .. }
507 | NautilusWsMessage::SendTxRejected { .. }
508 | NautilusWsMessage::SendTxBatchResult { .. }
509 | NautilusWsMessage::AccountStreamFirstFrame(_),
510 ) => {}
511 Some(NautilusWsMessage::Reconnected { .. }) => {
512 log::debug!("Lighter WebSocket reconnected");
513 }
514 None => {
515 log::debug!("Lighter WebSocket next_event returned None");
516 tokio::select! {
517 () = cancellation_token.cancelled() => {
518 log::debug!(
519 "Lighter WebSocket consumption loop cancelled"
520 );
521 break;
522 }
523 () = tokio::time::sleep(tokio::time::Duration::from_secs(1)) => {}
524 }
525 }
526 }
527 }
528 }
529 }
530
531 log::debug!("Lighter WebSocket consumption loop finished");
532 };
533
534 self.tasks
535 .spawn(future)
536 .context("failed to register Lighter WebSocket consumption task")?;
537 log::debug!("Lighter WebSocket consumption task spawned");
538
539 Ok(())
540 }
541
542 fn spawn_instrument_refresh(&self) -> anyhow::Result<()> {
543 let minutes = self.config.update_instruments_interval_mins;
544 if minutes == 0 {
545 log::debug!("Lighter instrument refresh disabled (interval=0)");
546 return Ok(());
547 }
548
549 let interval = Duration::from_secs(minutes.saturating_mul(60));
550 let cancellation = self.cancellation_token.clone();
551 let http_client = self.http_client.clone();
552 let instruments_cache = Arc::clone(&self.instruments);
553 let statuses = Arc::clone(&self.instrument_statuses);
554 let status_subscriptions = Arc::clone(&self.instrument_status_subscriptions);
555 let registry = Arc::clone(&self.registry);
556 let ws_client = self.ws_client.clone();
557 let data_sender = self.data_sender.clone();
558 let client_id = self.client_id;
559 let clock = self.clock;
560
561 let future = async move {
562 loop {
563 let sleep = tokio::time::sleep(interval);
564 tokio::pin!(sleep);
565 tokio::select! {
566 () = cancellation.cancelled() => {
567 log::debug!("Lighter instrument refresh task cancelled");
568 break;
569 }
570 () = &mut sleep => {
571 let Some(result) = await_instrument_refresh(
572 &cancellation,
573 http_client.request_instruments_with_status(),
574 ).await else {
575 log::debug!("Lighter instrument refresh task cancelled");
576 break;
577 };
578
579 match result {
580 Ok(items) => {
581 instruments_cache.rcu(|m| {
582 for (instrument, _) in &items {
583 m.insert(instrument.id(), instrument.clone());
584 }
585 });
586
587 let ws_cache: Vec<(i64, InstrumentAny)> = items
588 .iter()
589 .filter_map(|(instrument, _)| {
590 registry
591 .market_index(&instrument.id())
592 .map(|idx| (idx, instrument.clone()))
593 })
594 .collect();
595
596 if !ws_cache.is_empty() {
597 ws_client.cache_instruments(ws_cache);
598 }
599
600 statuses.clear();
601 let ts_init = clock.get_time_ns();
602
603 for (instrument, status) in &items {
604 cache_lighter_instrument_status(
605 &statuses,
606 instrument.id(),
607 *status,
608 );
609 emit_lighter_instrument_status_if_subscribed(
610 &data_sender,
611 &status_subscriptions,
612 instrument.id(),
613 *status,
614 ts_init,
615 ts_init,
616 );
617
618 if let Err(e) = data_sender
619 .send(DataEvent::Instrument(instrument.clone()))
620 {
621 log::warn!(
622 "Failed to send refreshed Lighter instrument: {e}"
623 );
624 }
625 }
626
627 log::debug!(
628 "Lighter instruments refreshed: client_id={client_id}, count={}",
629 items.len(),
630 );
631 }
632 Err(e) => {
633 log::warn!(
634 "Failed to refresh Lighter instruments: client_id={client_id}, error={e:?}",
635 );
636 }
637 }
638 }
639 }
640 }
641 };
642
643 self.tasks
644 .spawn(future)
645 .context("failed to register Lighter instrument refresh task")?;
646 Ok(())
647 }
648
649 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
650 self.tasks.begin_shutdown();
651 self.ws_client.begin_shutdown();
652
653 if let Err(e) = self.shutdown_tasks().await {
654 self.shutdown_errors.push(e.to_string());
655 }
656 let ws_client = self.take_ws_client();
657 if let Err(e) = ws_client
658 .disconnect_with_task_retention(Arc::clone(&self.ws_handler_retained))
659 .await
660 {
661 self.shutdown_errors.push(e.to_string());
662 }
663
664 if let Err(e) = self.ws_handler_retained.finish().await {
665 self.shutdown_errors.push(e.to_string());
666 }
667 self.is_connected.store(false, Ordering::Release);
668
669 self.take_shutdown_result("Failed to roll back Lighter data startup")
670 }
671
672 fn clear_market_stats_subscriptions(&self) {
673 self.market_stats_subscriptions.clear();
674 self.market_stats_subscription_generations.clear();
675 }
676
677 fn clear_instrument_status_subscriptions(&self) {
678 self.instrument_status_subscriptions.clear();
679 }
680
681 fn emit_cached_instrument_status(&self, instrument_id: InstrumentId) -> bool {
682 let Some(status) = self
683 .instrument_statuses
684 .get(&instrument_id)
685 .map(|status| *status)
686 else {
687 return false;
688 };
689
690 let ts_init = self.clock.get_time_ns();
691 emit_lighter_instrument_status(&self.data_sender, instrument_id, status, ts_init, ts_init);
692 true
693 }
694
695 fn activate_market_stats_subscription(
696 &self,
697 instrument_id: InstrumentId,
698 channel: LighterWsChannel,
699 kind: MarketStatsKind,
700 label: &'static str,
701 ) {
702 let generation_entry = self
703 .market_stats_subscription_generations
704 .entry(instrument_id)
705 .or_insert_with(|| {
706 self.next_market_stats_subscription_generation
707 .fetch_add(1, Ordering::Relaxed)
708 });
709 let generation = *generation_entry;
710
711 let subscribe_channel = match self.market_stats_subscriptions.entry(instrument_id) {
712 Entry::Occupied(mut entry) => {
713 let subscription = entry.get_mut();
714 let should_subscribe = subscription.flags.is_empty();
715 subscription.flags.insert(kind);
716 should_subscribe.then(|| subscription.channel.clone())
717 }
718 Entry::Vacant(entry) => {
719 entry.insert(MarketStatsSubscription::new(channel.clone(), kind));
720 Some(channel)
721 }
722 };
723 drop(generation_entry);
724
725 if let Some(channel) = subscribe_channel {
726 let ws = self.ws_client.clone();
727 let subscriptions = Arc::clone(&self.market_stats_subscriptions);
728 let generations = Arc::clone(&self.market_stats_subscription_generations);
729 self.spawn_task(async move {
730 if let Err(e) = subscribe_market_stats_channel(ws, channel).await {
731 log::error!("Failed to subscribe to Lighter {label}: {e:?}");
732
733 rollback_market_stats_subscription(
736 &subscriptions,
737 &generations,
738 instrument_id,
739 generation,
740 );
741 }
742 });
743 }
744 }
745
746 fn deactivate_market_stats_subscription(
747 &self,
748 instrument_id: InstrumentId,
749 kind: MarketStatsKind,
750 label: &'static str,
751 ) {
752 let generation = self
754 .market_stats_subscription_generations
755 .entry(instrument_id);
756 let unsubscribe_channel = match self.market_stats_subscriptions.entry(instrument_id) {
757 Entry::Occupied(mut entry) => {
758 entry.get_mut().flags.remove(kind);
759 if entry.get().flags.is_empty() {
760 if let Entry::Occupied(generation) = generation {
761 generation.remove();
762 }
763 Some(entry.remove().channel)
764 } else {
765 None
766 }
767 }
768 Entry::Vacant(_) => None,
769 };
770
771 if let Some(channel) = unsubscribe_channel {
772 let ws = self.ws_client.clone();
773 self.spawn_task(async move {
774 if let Err(e) = unsubscribe_market_stats_channel(ws, channel).await {
775 log::error!("Failed to unsubscribe from Lighter {label}: {e:?}");
776 }
777 });
778 }
779 }
780
781 fn perp_market_stats_channel(
782 &self,
783 instrument_id: InstrumentId,
784 label: &str,
785 ) -> anyhow::Result<LighterWsChannel> {
786 let instrument = self
787 .instruments
788 .get_cloned(&instrument_id)
789 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
790
791 anyhow::ensure!(
792 matches!(instrument, InstrumentAny::CryptoPerpetual(_)),
793 "Lighter {label} subscriptions require a perpetual instrument: {instrument_id}",
794 );
795
796 let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
797 anyhow::anyhow!("No Lighter market_index registered for {instrument_id}")
798 })?;
799
800 Ok(LighterWsChannel::MarketStats(
801 LighterMarketSelection::Market(market_index),
802 ))
803 }
804
805 fn index_market_stats_channel(
806 &self,
807 instrument_id: InstrumentId,
808 ) -> anyhow::Result<LighterWsChannel> {
809 let instrument = self
810 .instruments
811 .get_cloned(&instrument_id)
812 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
813 let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
814 anyhow::anyhow!("No Lighter market_index registered for {instrument_id}")
815 })?;
816
817 match instrument {
818 InstrumentAny::CryptoPerpetual(_) => Ok(LighterWsChannel::MarketStats(
819 LighterMarketSelection::Market(market_index),
820 )),
821 InstrumentAny::CurrencyPair(_) => Ok(LighterWsChannel::SpotMarketStats(
822 LighterMarketSelection::Market(market_index),
823 )),
824 _ => anyhow::bail!(
825 "Lighter index price subscriptions require a perpetual or spot instrument: {instrument_id}",
826 ),
827 }
828 }
829}
830
831async fn await_instrument_refresh<T>(
832 cancellation: &CancellationToken,
833 request: impl std::future::Future<Output = T>,
834) -> Option<T> {
835 tokio::select! {
836 biased;
837 () = cancellation.cancelled() => None,
838 result = request => (!cancellation.is_cancelled()).then_some(result),
839 }
840}
841
842fn cache_lighter_instrument_status(
843 statuses: &DashMap<InstrumentId, LighterMarketStatus>,
844 instrument_id: InstrumentId,
845 status: LighterMarketStatus,
846) {
847 statuses.insert(instrument_id, status);
848}
849
850fn rollback_market_stats_subscription(
851 subscriptions: &DashMap<InstrumentId, MarketStatsSubscription>,
852 generations: &DashMap<InstrumentId, u64>,
853 instrument_id: InstrumentId,
854 failed_generation: u64,
855) {
856 let Entry::Occupied(generation) = generations.entry(instrument_id) else {
857 return;
858 };
859
860 if *generation.get() != failed_generation {
861 return;
862 }
863
864 subscriptions.remove(&instrument_id);
865 generation.remove();
866}
867
868fn emit_lighter_instrument_status_if_subscribed(
869 sender: &EventSender<DataEvent>,
870 subscriptions: &DashSet<InstrumentId>,
871 instrument_id: InstrumentId,
872 status: LighterMarketStatus,
873 ts_event: UnixNanos,
874 ts_init: UnixNanos,
875) {
876 if subscriptions.contains(&instrument_id) {
877 emit_lighter_instrument_status(sender, instrument_id, status, ts_event, ts_init);
878 }
879}
880
881fn emit_lighter_instrument_status(
882 sender: &EventSender<DataEvent>,
883 instrument_id: InstrumentId,
884 status: LighterMarketStatus,
885 ts_event: UnixNanos,
886 ts_init: UnixNanos,
887) {
888 let action = lighter_market_status_action(status);
889 let is_trading = Some(matches!(action, MarketStatusAction::Trading));
890 let status = InstrumentStatus::new(
891 instrument_id,
892 action,
893 ts_event,
894 ts_init,
895 None,
896 None,
897 is_trading,
898 None,
899 None,
900 );
901
902 if let Err(e) = sender.send(DataEvent::InstrumentStatus(status)) {
903 log::error!("Failed to send Lighter instrument status: {e}");
904 }
905}
906
907fn lighter_market_status_action(status: LighterMarketStatus) -> MarketStatusAction {
908 match status {
909 LighterMarketStatus::Active => MarketStatusAction::Trading,
910 LighterMarketStatus::Inactive => MarketStatusAction::NotAvailableForTrading,
911 }
912}
913
914#[async_trait::async_trait(?Send)]
915impl DataClient for LighterDataClient {
916 fn client_id(&self) -> ClientId {
917 self.client_id
918 }
919
920 fn venue(&self) -> Option<Venue> {
921 Some(self.venue())
922 }
923
924 fn start(&mut self) -> anyhow::Result<()> {
925 log::info!(
926 "Starting Lighter data client: client_id={}, environment={:?}, has_credentials={}",
927 self.client_id,
928 self.config.environment,
929 self.has_credentials(),
930 );
931 Ok(())
932 }
933
934 fn stop(&mut self) -> anyhow::Result<()> {
935 log::info!("Stopping Lighter data client {}", self.client_id);
936 self.abort_tasks();
937 self.spawn_ws_disconnect();
938 self.is_connected.store(false, Ordering::Release);
939 self.clear_instrument_status_subscriptions();
940 self.clear_market_stats_subscriptions();
941 Ok(())
942 }
943
944 fn reset(&mut self) -> anyhow::Result<()> {
945 log::debug!("Resetting Lighter data client {}", self.client_id);
946 self.abort_tasks();
947 self.spawn_ws_disconnect();
948 self.is_connected.store(false, Ordering::Release);
949 self.clear_instrument_status_subscriptions();
950 self.clear_market_stats_subscriptions();
951 Ok(())
952 }
953
954 fn dispose(&mut self) -> anyhow::Result<()> {
955 log::debug!("Disposing Lighter data client {}", self.client_id);
956 self.stop()
957 }
958
959 fn is_connected(&self) -> bool {
960 self.is_connected.load(Ordering::Acquire)
961 }
962
963 fn is_disconnected(&self) -> bool {
964 !self.is_connected()
965 }
966
967 async fn connect(&mut self) -> anyhow::Result<()> {
968 if self.is_connected()
969 && self.tasks.is_open()
970 && self.ws_disconnect_handle.is_none()
971 && self.ws_handler_retained.is_empty()
972 {
973 return Ok(());
974 }
975
976 if !self.tasks.is_open()
977 || !self.tasks.is_empty()
978 || self.ws_disconnect_handle.is_some()
979 || !self.ws_handler_retained.is_empty()
980 {
981 self.teardown_partial_connect().await?;
982 }
983
984 if !self.tasks.is_open() {
985 self.tasks.start_generation().map_err(|e| {
986 anyhow::anyhow!("Failed to start Lighter data task generation: {e}")
987 })?;
988 self.cancellation_token = self.tasks.cancellation_token();
989 }
990
991 let ws_client = self.ws_client.clone();
992 let setup_guard = TaskGroupGuard::new(&[&self.tasks], move || {
993 ws_client.begin_shutdown();
994 });
995
996 let instruments = self
997 .bootstrap_instruments()
998 .await
999 .context("failed to bootstrap Lighter instruments")?;
1000
1001 for instrument in instruments {
1002 if let Err(e) = self.data_sender.send(DataEvent::Instrument(instrument)) {
1003 log::warn!("Failed to send instrument: {e}");
1004 }
1005 }
1006
1007 let session_result = async {
1008 self.spawn_ws()
1009 .await
1010 .context("failed to spawn Lighter WebSocket consumer")?;
1011 self.spawn_instrument_refresh()?;
1012 Ok::<(), anyhow::Error>(())
1013 }
1014 .await;
1015
1016 if let Err(e) = session_result {
1017 if let Err(teardown_error) = self.teardown_partial_connect().await {
1018 return Err(e.context(format!(
1019 "Lighter data startup teardown failed: {teardown_error}"
1020 )));
1021 }
1022 return Err(e);
1023 }
1024
1025 setup_guard.disarm();
1026 self.is_connected.store(true, Ordering::Relaxed);
1027 log::info!("Connected: client_id={}", self.client_id);
1028
1029 Ok(())
1030 }
1031
1032 async fn disconnect(&mut self) -> anyhow::Result<()> {
1033 if !self.is_connected()
1034 && self.tasks.is_empty()
1035 && self.tasks.is_open()
1036 && self.ws_disconnect_handle.is_none()
1037 && self.ws_handler_retained.is_empty()
1038 && self.shutdown_errors.is_empty()
1039 {
1040 return Ok(());
1041 }
1042
1043 self.tasks.begin_shutdown();
1044 self.ws_client.begin_shutdown();
1045 self.clear_instrument_status_subscriptions();
1046 self.clear_market_stats_subscriptions();
1047
1048 if let Err(e) = self.shutdown_tasks().await {
1049 self.shutdown_errors.push(e.to_string());
1050 }
1051
1052 let ws_client = self.take_ws_client();
1053 if let Err(e) = ws_client
1054 .disconnect_with_task_retention(Arc::clone(&self.ws_handler_retained))
1055 .await
1056 {
1057 self.shutdown_errors.push(e.to_string());
1058 }
1059
1060 if let Err(e) = self.ws_handler_retained.finish().await {
1061 self.shutdown_errors.push(e.to_string());
1062 }
1063
1064 self.instruments.store(AHashMap::new());
1065 self.instrument_statuses.clear();
1066 self.registry.clear();
1067
1068 self.is_connected.store(false, Ordering::Relaxed);
1069 log::info!("Disconnected: client_id={}", self.client_id);
1070
1071 self.take_shutdown_result("Failed to disconnect Lighter data client")
1072 }
1073
1074 fn subscribe_instrument(&mut self, cmd: SubscribeInstrument) -> anyhow::Result<()> {
1075 let instruments = self.instruments.load();
1076 if let Some(instrument) = instruments.get(&cmd.instrument_id) {
1077 if let Err(e) = self
1078 .data_sender
1079 .send(DataEvent::Instrument(instrument.clone()))
1080 {
1081 log::error!("Failed to send instrument {}: {e}", cmd.instrument_id);
1082 }
1083 } else {
1084 log::warn!("Instrument {} not found in cache", cmd.instrument_id);
1085 }
1086 Ok(())
1087 }
1088
1089 fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
1090 log::debug!(
1091 "Unsubscribing from instrument: {} (cache replay only)",
1092 cmd.instrument_id,
1093 );
1094 Ok(())
1095 }
1096
1097 fn subscribe_instrument_status(
1098 &mut self,
1099 subscription: SubscribeInstrumentStatus,
1100 ) -> anyhow::Result<()> {
1101 let instrument_id = subscription.instrument_id;
1102
1103 self.instrument_status_subscriptions.insert(instrument_id);
1104 if self.emit_cached_instrument_status(instrument_id) {
1105 return Ok(());
1106 }
1107
1108 let http = self.http_client.clone();
1109 let ws = self.ws_client.clone();
1110 let registry = Arc::clone(&self.registry);
1111 let sender = self.data_sender.clone();
1112 let instruments_cache = Arc::clone(&self.instruments);
1113 let statuses = Arc::clone(&self.instrument_statuses);
1114 let subscriptions = Arc::clone(&self.instrument_status_subscriptions);
1115 let clock = self.clock;
1116
1117 self.spawn_task(async move {
1118 match http.request_instrument_with_status(instrument_id).await {
1119 Ok((instrument, status)) => {
1120 instruments_cache.rcu(|map| {
1121 map.insert(instrument.id(), instrument.clone());
1122 });
1123
1124 if let Some(market_index) = registry.market_index(&instrument.id()) {
1125 ws.cache_instrument(market_index, instrument.clone());
1126 }
1127
1128 cache_lighter_instrument_status(&statuses, instrument.id(), status);
1129 let ts_init = clock.get_time_ns();
1130 emit_lighter_instrument_status_if_subscribed(
1131 &sender,
1132 &subscriptions,
1133 instrument.id(),
1134 status,
1135 ts_init,
1136 ts_init,
1137 );
1138 }
1139 Err(e) => {
1140 log::error!(
1141 "Failed to fetch Lighter instrument status for {instrument_id}: {e:?}"
1142 );
1143 }
1144 }
1145 });
1146
1147 Ok(())
1148 }
1149
1150 fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
1151 validate_book_deltas_subscription(subscription.book_type)?;
1152
1153 let ws = self.ws_client.clone();
1154 let instrument_id = subscription.instrument_id;
1155
1156 self.spawn_task(async move {
1157 if let Err(e) = ws.subscribe_book(instrument_id).await {
1158 log::error!("Failed to subscribe to Lighter book deltas: {e:?}");
1159 }
1160 });
1161
1162 Ok(())
1163 }
1164
1165 fn subscribe_book_depth(&mut self, subscription: SubscribeBookDepth) -> anyhow::Result<()> {
1166 log::debug!("Subscribing to book depth: {}", subscription.instrument_id);
1167
1168 validate_book_depth_subscription(subscription.book_type)?;
1169
1170 let ws = self.ws_client.clone();
1171 let instrument_id = subscription.instrument_id;
1172
1173 self.spawn_task(async move {
1174 if let Err(e) = ws.subscribe_book_depth(instrument_id).await {
1175 log::error!("Failed to subscribe to Lighter book depth: {e:?}");
1176 }
1177 });
1178
1179 Ok(())
1180 }
1181
1182 fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
1183 let ws = self.ws_client.clone();
1184 let instrument_id = subscription.instrument_id;
1185
1186 self.spawn_task(async move {
1187 if let Err(e) = ws.subscribe_quotes(instrument_id).await {
1188 log::error!("Failed to subscribe to Lighter quotes: {e:?}");
1189 }
1190 });
1191
1192 Ok(())
1193 }
1194
1195 fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
1196 let ws = self.ws_client.clone();
1197 let instrument_id = subscription.instrument_id;
1198
1199 self.spawn_task(async move {
1200 if let Err(e) = ws.subscribe_trades(instrument_id).await {
1201 log::error!("Failed to subscribe to Lighter trades: {e:?}");
1202 }
1203 });
1204
1205 Ok(())
1206 }
1207
1208 fn subscribe_mark_prices(&mut self, subscription: SubscribeMarkPrices) -> anyhow::Result<()> {
1209 let instrument_id = subscription.instrument_id;
1210
1211 let channel = self.perp_market_stats_channel(instrument_id, "mark price")?;
1212 self.activate_market_stats_subscription(
1213 instrument_id,
1214 channel,
1215 MarketStatsKind::MarkPrice,
1216 "mark price",
1217 );
1218
1219 Ok(())
1220 }
1221
1222 fn subscribe_index_prices(&mut self, subscription: SubscribeIndexPrices) -> anyhow::Result<()> {
1223 let instrument_id = subscription.instrument_id;
1224
1225 let channel = self.index_market_stats_channel(instrument_id)?;
1226 self.activate_market_stats_subscription(
1227 instrument_id,
1228 channel,
1229 MarketStatsKind::IndexPrice,
1230 "index price",
1231 );
1232
1233 Ok(())
1234 }
1235
1236 fn subscribe_funding_rates(
1237 &mut self,
1238 subscription: SubscribeFundingRates,
1239 ) -> anyhow::Result<()> {
1240 let instrument_id = subscription.instrument_id;
1241
1242 let channel = self.perp_market_stats_channel(instrument_id, "funding rate")?;
1243 self.activate_market_stats_subscription(
1244 instrument_id,
1245 channel,
1246 MarketStatsKind::FundingRate,
1247 "funding rate",
1248 );
1249
1250 Ok(())
1251 }
1252
1253 fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
1254 let bar_type = subscription.bar_type;
1255
1256 let resolution = LighterCandleResolution::try_from(&bar_type)?;
1257 anyhow::ensure!(
1258 resolution.is_ws_streamable(),
1259 "Lighter does not offer {bar_type} on the candle WebSocket stream",
1260 );
1261
1262 let instrument_id = bar_type.instrument_id();
1263 if !self.instruments.contains_key(&instrument_id) {
1264 return Err(InstrumentLookupError::not_found(instrument_id).into());
1265 }
1266
1267 let ws = self.ws_client.clone();
1268 self.spawn_task(async move {
1269 if let Err(e) = ws.subscribe_candles(instrument_id, resolution).await {
1270 log::error!("Failed to subscribe to Lighter candles for {bar_type}: {e:?}");
1271 }
1272 });
1273
1274 Ok(())
1275 }
1276
1277 fn unsubscribe_book_deltas(
1278 &mut self,
1279 unsubscription: &UnsubscribeBookDeltas,
1280 ) -> anyhow::Result<()> {
1281 log::debug!(
1282 "Unsubscribing from book deltas: {}",
1283 unsubscription.instrument_id
1284 );
1285
1286 let ws = self.ws_client.clone();
1287 let instrument_id = unsubscription.instrument_id;
1288
1289 self.spawn_task(async move {
1290 if let Err(e) = ws.unsubscribe_book(instrument_id).await {
1291 log::error!("Failed to unsubscribe from Lighter book deltas: {e:?}");
1292 }
1293 });
1294
1295 Ok(())
1296 }
1297
1298 fn unsubscribe_book_depth(
1299 &mut self,
1300 unsubscription: &UnsubscribeBookDepth,
1301 ) -> anyhow::Result<()> {
1302 log::debug!(
1303 "Unsubscribing from book depth: {}",
1304 unsubscription.instrument_id
1305 );
1306
1307 let ws = self.ws_client.clone();
1308 let instrument_id = unsubscription.instrument_id;
1309
1310 self.spawn_task(async move {
1311 if let Err(e) = ws.unsubscribe_book_depth(instrument_id).await {
1312 log::error!("Failed to unsubscribe from Lighter book depth: {e:?}");
1313 }
1314 });
1315
1316 Ok(())
1317 }
1318
1319 fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
1320 log::debug!(
1321 "Unsubscribing from quotes: {}",
1322 unsubscription.instrument_id
1323 );
1324
1325 let ws = self.ws_client.clone();
1326 let instrument_id = unsubscription.instrument_id;
1327
1328 self.spawn_task(async move {
1329 if let Err(e) = ws.unsubscribe_quotes(instrument_id).await {
1330 log::error!("Failed to unsubscribe from Lighter quotes: {e:?}");
1331 }
1332 });
1333
1334 Ok(())
1335 }
1336
1337 fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
1338 log::debug!(
1339 "Unsubscribing from trades: {}",
1340 unsubscription.instrument_id
1341 );
1342
1343 let ws = self.ws_client.clone();
1344 let instrument_id = unsubscription.instrument_id;
1345
1346 self.spawn_task(async move {
1347 if let Err(e) = ws.unsubscribe_trades(instrument_id).await {
1348 log::error!("Failed to unsubscribe from Lighter trades: {e:?}");
1349 }
1350 });
1351
1352 Ok(())
1353 }
1354
1355 fn unsubscribe_instrument_status(
1356 &mut self,
1357 unsubscription: &UnsubscribeInstrumentStatus,
1358 ) -> anyhow::Result<()> {
1359 let instrument_id = unsubscription.instrument_id;
1360
1361 self.instrument_status_subscriptions.remove(&instrument_id);
1362
1363 Ok(())
1364 }
1365
1366 fn unsubscribe_mark_prices(
1367 &mut self,
1368 unsubscription: &UnsubscribeMarkPrices,
1369 ) -> anyhow::Result<()> {
1370 let instrument_id = unsubscription.instrument_id;
1371
1372 self.deactivate_market_stats_subscription(
1373 instrument_id,
1374 MarketStatsKind::MarkPrice,
1375 "mark price",
1376 );
1377
1378 Ok(())
1379 }
1380
1381 fn unsubscribe_index_prices(
1382 &mut self,
1383 unsubscription: &UnsubscribeIndexPrices,
1384 ) -> anyhow::Result<()> {
1385 let instrument_id = unsubscription.instrument_id;
1386
1387 self.deactivate_market_stats_subscription(
1388 instrument_id,
1389 MarketStatsKind::IndexPrice,
1390 "index price",
1391 );
1392
1393 Ok(())
1394 }
1395
1396 fn unsubscribe_funding_rates(
1397 &mut self,
1398 unsubscription: &UnsubscribeFundingRates,
1399 ) -> anyhow::Result<()> {
1400 let instrument_id = unsubscription.instrument_id;
1401
1402 self.deactivate_market_stats_subscription(
1403 instrument_id,
1404 MarketStatsKind::FundingRate,
1405 "funding rate",
1406 );
1407
1408 Ok(())
1409 }
1410
1411 fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
1412 let bar_type = unsubscription.bar_type;
1413
1414 let resolution = match LighterCandleResolution::try_from(&bar_type) {
1415 Ok(resolution) => resolution,
1416 Err(e) => {
1417 log::warn!("Skipping Lighter candle unsubscribe for {bar_type}: {e}");
1418 return Ok(());
1419 }
1420 };
1421
1422 let instrument_id = bar_type.instrument_id();
1423 let ws = self.ws_client.clone();
1424 self.spawn_task(async move {
1425 if let Err(e) = ws.unsubscribe_candles(instrument_id, resolution).await {
1426 log::error!("Failed to unsubscribe from Lighter candles for {bar_type}: {e:?}");
1427 }
1428 });
1429
1430 Ok(())
1431 }
1432
1433 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1434 log::debug!("Requesting Lighter instruments");
1435
1436 let http = self.http_client.clone();
1437 let ws = self.ws_client.clone();
1438 let registry = Arc::clone(&self.registry);
1439 let sender = self.data_sender.clone();
1440 let instruments_cache = Arc::clone(&self.instruments);
1441 let status_cache = Arc::clone(&self.instrument_statuses);
1442 let status_subscriptions = Arc::clone(&self.instrument_status_subscriptions);
1443 let request_id = request.request_id;
1444 let client_id = request.client_id.unwrap_or(self.client_id);
1445 let venue = self.venue();
1446 let start_nanos = datetime_to_unix_nanos(request.start);
1447 let end_nanos = datetime_to_unix_nanos(request.end);
1448 let params = request.params;
1449 let clock = self.clock;
1450
1451 self.spawn_task(async move {
1452 match http.request_instruments_with_status().await {
1453 Ok(instruments_with_status) => {
1454 let instruments: Vec<InstrumentAny> = instruments_with_status
1455 .iter()
1456 .map(|(instrument, _)| instrument.clone())
1457 .collect();
1458
1459 instruments_cache.rcu(|map| {
1460 for instrument in &instruments {
1461 map.insert(instrument.id(), instrument.clone());
1462 }
1463 });
1464
1465 let ws_cache: Vec<(i64, InstrumentAny)> = instruments
1466 .iter()
1467 .filter_map(|i| registry.market_index(&i.id()).map(|idx| (idx, i.clone())))
1468 .collect();
1469
1470 if !ws_cache.is_empty() {
1471 ws.cache_instruments(ws_cache);
1472 }
1473
1474 status_cache.clear();
1475 let ts_init = clock.get_time_ns();
1476
1477 for (instrument, status) in &instruments_with_status {
1478 cache_lighter_instrument_status(&status_cache, instrument.id(), *status);
1479 emit_lighter_instrument_status_if_subscribed(
1480 &sender,
1481 &status_subscriptions,
1482 instrument.id(),
1483 *status,
1484 ts_init,
1485 ts_init,
1486 );
1487 }
1488
1489 let response = DataResponse::Instruments(InstrumentsResponse::new(
1490 request_id,
1491 client_id,
1492 venue,
1493 instruments,
1494 start_nanos,
1495 end_nanos,
1496 clock.get_time_ns(),
1497 params,
1498 ));
1499
1500 if let Err(e) = sender.send(DataEvent::Response(response)) {
1501 log::error!("Failed to send instruments response: {e}");
1502 }
1503 }
1504 Err(e) => {
1505 log::error!("Failed to fetch Lighter instruments: {e:?}");
1506 }
1507 }
1508 });
1509
1510 Ok(())
1511 }
1512
1513 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1514 log::debug!("Requesting Lighter instrument: {}", request.instrument_id);
1515
1516 let http = self.http_client.clone();
1517 let ws = self.ws_client.clone();
1518 let registry = Arc::clone(&self.registry);
1519 let sender = self.data_sender.clone();
1520 let instruments_cache = Arc::clone(&self.instruments);
1521 let status_cache = Arc::clone(&self.instrument_statuses);
1522 let status_subscriptions = Arc::clone(&self.instrument_status_subscriptions);
1523 let instrument_id = request.instrument_id;
1524 let request_id = request.request_id;
1525 let client_id = request.client_id.unwrap_or(self.client_id);
1526 let start_nanos = datetime_to_unix_nanos(request.start);
1527 let end_nanos = datetime_to_unix_nanos(request.end);
1528 let params = request.params;
1529 let clock = self.clock;
1530
1531 self.spawn_task(async move {
1532 match http.request_instrument_with_status(instrument_id).await {
1533 Ok((instrument, status)) => {
1534 instruments_cache.rcu(|map| {
1535 map.insert(instrument.id(), instrument.clone());
1536 });
1537
1538 if let Some(market_index) = registry.market_index(&instrument.id()) {
1539 ws.cache_instrument(market_index, instrument.clone());
1540 }
1541
1542 cache_lighter_instrument_status(&status_cache, instrument.id(), status);
1543 let ts_init = clock.get_time_ns();
1544 emit_lighter_instrument_status_if_subscribed(
1545 &sender,
1546 &status_subscriptions,
1547 instrument.id(),
1548 status,
1549 ts_init,
1550 ts_init,
1551 );
1552
1553 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1554 request_id,
1555 client_id,
1556 instrument.id(),
1557 instrument,
1558 start_nanos,
1559 end_nanos,
1560 clock.get_time_ns(),
1561 params,
1562 )));
1563
1564 if let Err(e) = sender.send(DataEvent::Response(response)) {
1565 log::error!("Failed to send instrument response: {e}");
1566 }
1567 }
1568 Err(e) => {
1569 log::error!("Failed to fetch Lighter instrument {instrument_id}: {e:?}");
1570 }
1571 }
1572 });
1573
1574 Ok(())
1575 }
1576
1577 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1578 let bar_type = request.bar_type;
1579 log::debug!("Requesting Lighter bars for {bar_type}");
1580
1581 LighterCandleResolution::try_from(&bar_type)?;
1582
1583 let instrument_id = bar_type.instrument_id();
1584 let instrument = self
1585 .instruments
1586 .get_cloned(&instrument_id)
1587 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1588
1589 let http = self.http_client.clone();
1590 let sender = self.data_sender.clone();
1591 let start = request.start;
1592 let end = request.end;
1593 let limit = request.limit.map(|n| n.get() as u32);
1594 let request_id = request.request_id;
1595 let client_id = request.client_id.unwrap_or(self.client_id);
1596 let params = request.params;
1597 let clock = self.clock;
1598 let start_nanos = datetime_to_unix_nanos(start);
1599 let end_nanos = datetime_to_unix_nanos(end);
1600
1601 self.spawn_task(async move {
1602 match http
1603 .request_bars(&instrument, bar_type, start, end, limit)
1604 .await
1605 {
1606 Ok(bars) => {
1607 let response = DataResponse::Bars(BarsResponse::new(
1608 request_id,
1609 client_id,
1610 bar_type,
1611 bars,
1612 start_nanos,
1613 end_nanos,
1614 clock.get_time_ns(),
1615 params,
1616 ));
1617
1618 if let Err(e) = sender.send(DataEvent::Response(response)) {
1619 log::error!("Failed to send bars response: {e}");
1620 }
1621 }
1622 Err(e) => {
1623 log::error!("Lighter bars request failed for {instrument_id}: {e:?}");
1624 }
1625 }
1626 });
1627
1628 Ok(())
1629 }
1630
1631 fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
1632 anyhow::bail!(
1633 "Lighter does not support historical quote requests for {}; \
1634 subscribe to quotes via WebSocket for live BBO ticks",
1635 request.instrument_id,
1636 )
1637 }
1638
1639 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1640 let instrument_id = request.instrument_id;
1641 log::debug!("Requesting Lighter trades for {instrument_id}");
1642
1643 let instrument = self
1644 .instruments
1645 .get_cloned(&instrument_id)
1646 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1647
1648 let http = self.http_client.clone();
1649 let sender = self.data_sender.clone();
1650 let request_id = request.request_id;
1651 let client_id = request.client_id.unwrap_or(self.client_id);
1652 let limit = clamp_recent_trades_limit(request.limit);
1653 let start_nanos = datetime_to_unix_nanos(request.start);
1654 let end_nanos = datetime_to_unix_nanos(request.end);
1655 let params = request.params;
1656 let clock = self.clock;
1657
1658 self.spawn_task(async move {
1659 match http.request_recent_trades(&instrument, limit).await {
1660 Ok(mut trades) => {
1661 retain_trade_ticks_in_range(&mut trades, start_nanos, end_nanos);
1662
1663 let response = DataResponse::Trades(TradesResponse::new(
1664 request_id,
1665 client_id,
1666 instrument_id,
1667 trades,
1668 start_nanos,
1669 end_nanos,
1670 clock.get_time_ns(),
1671 params,
1672 ));
1673
1674 if let Err(e) = sender.send(DataEvent::Response(response)) {
1675 log::error!("Failed to send trades response: {e}");
1676 }
1677 }
1678 Err(e) => {
1679 log::error!("Lighter trades request failed for {instrument_id}: {e}");
1680 }
1681 }
1682 });
1683
1684 Ok(())
1685 }
1686
1687 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1688 let instrument_id = request.instrument_id;
1689 log::debug!("Requesting Lighter funding rates for {instrument_id}");
1690
1691 let instrument = self
1692 .instruments
1693 .get_cloned(&instrument_id)
1694 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1695
1696 anyhow::ensure!(
1697 matches!(instrument, InstrumentAny::CryptoPerpetual(_)),
1698 "Lighter funding-rate requests require a perpetual instrument: {instrument_id}",
1699 );
1700
1701 let http = self.http_client.clone();
1702 let sender = self.data_sender.clone();
1703 let request_id = request.request_id;
1704 let client_id = request.client_id.unwrap_or(self.client_id);
1705 let start = request.start;
1706 let end = request.end;
1707 let limit = request.limit.map(|n| n.get());
1708 let start_nanos = datetime_to_unix_nanos(start);
1709 let end_nanos = datetime_to_unix_nanos(end);
1710 let params = request.params;
1711 let clock = self.clock;
1712
1713 self.spawn_task(async move {
1714 match http
1715 .request_funding_rates(&instrument, start, end, limit)
1716 .await
1717 {
1718 Ok(funding_rates) => {
1719 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1720 request_id,
1721 client_id,
1722 instrument_id,
1723 funding_rates,
1724 start_nanos,
1725 end_nanos,
1726 clock.get_time_ns(),
1727 params,
1728 ));
1729
1730 if let Err(e) = sender.send(DataEvent::Response(response)) {
1731 log::error!("Failed to send funding rates response: {e}");
1732 }
1733 }
1734 Err(e) => {
1735 log::error!("Lighter funding rates request failed for {instrument_id}: {e:?}");
1736 }
1737 }
1738 });
1739
1740 Ok(())
1741 }
1742
1743 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1744 let instrument_id = request.instrument_id;
1745 log::debug!("Requesting Lighter book snapshot for {instrument_id}");
1746
1747 let instrument = self
1748 .instruments
1749 .get_cloned(&instrument_id)
1750 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1751
1752 let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
1753 anyhow::anyhow!("No Lighter market_index registered for {instrument_id}")
1754 })?;
1755
1756 let http = self.http_client.clone();
1757 let sender = self.data_sender.clone();
1758 let request_id = request.request_id;
1759 let client_id = request.client_id.unwrap_or(self.client_id);
1760 let limit = clamp_book_snapshot_limit(request.depth);
1761 let params = request.params;
1762 let clock = self.clock;
1763 let price_precision = instrument.price_precision();
1764 let size_precision = instrument.size_precision();
1765
1766 let query = LighterOrderBookOrdersQuery {
1767 market_id: market_index,
1768 limit,
1769 };
1770
1771 self.spawn_task(async move {
1772 match http.inner.get_order_book_orders(&query).await {
1773 Ok(snapshot) => {
1774 let ts_init = clock.get_time_ns();
1775 let book = parse_l2_order_book_snapshot(
1776 &snapshot,
1777 instrument_id,
1778 price_precision,
1779 size_precision,
1780 );
1781
1782 let response = DataResponse::Book(BookResponse::new(
1783 request_id,
1784 client_id,
1785 instrument_id,
1786 book,
1787 None,
1788 None,
1789 ts_init,
1790 params,
1791 ));
1792
1793 if let Err(e) = sender.send(DataEvent::Response(response)) {
1794 log::error!("Failed to send book snapshot response: {e}");
1795 }
1796 }
1797 Err(e) => {
1798 log::error!("Lighter book snapshot request failed for {instrument_id}: {e:?}");
1799 }
1800 }
1801 });
1802
1803 Ok(())
1804 }
1805
1806 fn request_book_depth(&self, request: RequestBookDepth) -> anyhow::Result<()> {
1807 anyhow::bail!(
1808 "Lighter does not support historical order book depth requests for {}; \
1809 use request_book_snapshot for an L2 snapshot or subscribe_book_depth for live depth",
1810 request.instrument_id,
1811 )
1812 }
1813}
1814
1815fn retain_trade_ticks_in_range(
1816 trades: &mut Vec<TradeTick>,
1817 start_nanos: Option<UnixNanos>,
1818 end_nanos: Option<UnixNanos>,
1819) {
1820 trades.retain(|trade| trade_tick_in_range(trade.ts_event, start_nanos, end_nanos));
1821 trades.sort_by_key(|trade| trade.ts_event);
1822}
1823
1824fn trade_tick_in_range(
1825 ts_event: UnixNanos,
1826 start_nanos: Option<UnixNanos>,
1827 end_nanos: Option<UnixNanos>,
1828) -> bool {
1829 start_nanos.is_none_or(|start| ts_event >= start) && end_nanos.is_none_or(|end| ts_event <= end)
1830}
1831
1832fn validate_book_deltas_subscription(book_type: BookType) -> anyhow::Result<()> {
1837 validate_l2_mbp_book_type(book_type, "deltas")
1838}
1839
1840fn validate_book_depth_subscription(book_type: BookType) -> anyhow::Result<()> {
1841 validate_l2_mbp_book_type(book_type, "depth")
1842}
1843
1844fn validate_l2_mbp_book_type(book_type: BookType, label: &str) -> anyhow::Result<()> {
1845 anyhow::ensure!(
1846 book_type == BookType::L2_MBP,
1847 "Lighter only supports L2_MBP order book {label}",
1848 );
1849 Ok(())
1850}
1851
1852#[cfg(test)]
1853mod tests {
1854 use std::{num::NonZeroUsize, time::Duration};
1855
1856 use axum::{
1857 Router,
1858 extract::Query,
1859 http::StatusCode,
1860 response::{IntoResponse, Response},
1861 routing::get,
1862 };
1863 use jiff::Timestamp;
1864 use nautilus_common::live::runner::replace_data_event_sender;
1865 use nautilus_core::UUID4;
1866 use nautilus_model::{
1867 data::{
1868 BarSpecification, BarType, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
1869 TradeTick,
1870 },
1871 enums::{AggregationSource, AggressorSide, BarAggregation, PriceType},
1872 identifiers::{InstrumentId, Symbol, TradeId},
1873 instruments::{CryptoPerpetual, CurrencyPair},
1874 types::{Currency, Price, Quantity},
1875 };
1876 use rstest::rstest;
1877 use rust_decimal::Decimal;
1878
1879 use super::{
1880 limits::{LIGHTER_BOOK_ORDERS_MAX_LIMIT, LIGHTER_RECENT_TRADES_MAX_LIMIT},
1881 market_stats::{MarketStatsFlags, MarketStatsSubscription},
1882 *,
1883 };
1884 use crate::{
1885 common::{
1886 consts::LIGHTER_VENUE,
1887 enums::{LighterFundingResolution, LighterProductType},
1888 },
1889 http::query::{LighterFundingsQuery, LighterRecentTradesQuery},
1890 };
1891
1892 struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
1893
1894 impl Drop for DropSignal {
1895 fn drop(&mut self) {
1896 if let Some(sender) = self.0.take() {
1897 let _ = sender.send(());
1898 }
1899 }
1900 }
1901
1902 const HTTP_ORDER_BOOK_DETAILS: &str =
1903 include_str!("../../test_data/http_order_book_details.json");
1904 const HTTP_FUNDINGS: &str = include_str!("../../test_data/http_fundings.json");
1905 const HTTP_RECENT_TRADES: &str = include_str!("../../test_data/http_recent_trades.json");
1906 const HTTP_RECENT_TRADES_NULL: &str =
1907 include_str!("../../test_data/http_recent_trades_null.json");
1908 const HTTP_RECENT_TRADES_UNORDERED: &str =
1909 include_str!("../../test_data/http_recent_trades_unordered.json");
1910 const PRIVATE_KEY_HEX: &str =
1911 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
1912
1913 #[rstest]
1914 #[case::none_defaults_to_cap(None, LIGHTER_BOOK_ORDERS_MAX_LIMIT)]
1915 #[case::below_cap_passes_through(Some(10), 10)]
1916 #[case::at_cap_passes_through(
1917 Some(LIGHTER_BOOK_ORDERS_MAX_LIMIT as usize),
1918 LIGHTER_BOOK_ORDERS_MAX_LIMIT
1919 )]
1920 #[case::above_cap_clamps(Some(500), LIGHTER_BOOK_ORDERS_MAX_LIMIT)]
1921 #[case::usize_max_clamps(Some(usize::MAX), LIGHTER_BOOK_ORDERS_MAX_LIMIT)]
1922 fn test_clamp_book_snapshot_limit(#[case] depth: Option<usize>, #[case] expected: u16) {
1923 let depth = depth.map(|n| NonZeroUsize::new(n).expect("non-zero"));
1924 assert_eq!(clamp_book_snapshot_limit(depth), expected);
1925 }
1926
1927 #[rstest]
1928 #[case::none_defaults_to_cap(None, LIGHTER_RECENT_TRADES_MAX_LIMIT)]
1929 #[case::below_cap_passes_through(Some(10), 10)]
1930 #[case::at_cap_passes_through(
1931 Some(LIGHTER_RECENT_TRADES_MAX_LIMIT as usize),
1932 LIGHTER_RECENT_TRADES_MAX_LIMIT
1933 )]
1934 #[case::above_cap_clamps(Some(500), LIGHTER_RECENT_TRADES_MAX_LIMIT)]
1935 #[case::usize_max_clamps(Some(usize::MAX), LIGHTER_RECENT_TRADES_MAX_LIMIT)]
1936 fn test_clamp_recent_trades_limit(#[case] limit: Option<usize>, #[case] expected: u16) {
1937 let limit = limit.map(|n| NonZeroUsize::new(n).expect("non-zero"));
1938 assert_eq!(clamp_recent_trades_limit(limit), expected);
1939 }
1940
1941 #[rstest]
1942 fn test_new_uses_readonly_websocket_url() {
1943 let client = create_data_client_for_test();
1944
1945 assert_eq!(
1946 client.ws_client.url(),
1947 "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
1948 );
1949 }
1950
1951 #[rstest]
1952 fn test_validate_book_deltas_accepts_l2_mbp() {
1953 assert!(validate_book_deltas_subscription(BookType::L2_MBP).is_ok());
1954 }
1955
1956 #[rstest]
1957 #[case(BookType::L1_MBP)]
1958 #[case(BookType::L3_MBO)]
1959 fn test_validate_book_deltas_rejects_other_book_types(#[case] book_type: BookType) {
1960 let err = validate_book_deltas_subscription(book_type).unwrap_err();
1961 assert!(
1962 err.to_string().contains("L2_MBP"),
1963 "expected error to cite L2_MBP, was: {err}",
1964 );
1965 }
1966
1967 #[rstest]
1968 fn test_validate_book_depth_accepts_l2_mbp() {
1969 assert!(validate_book_depth_subscription(BookType::L2_MBP).is_ok());
1970 }
1971
1972 #[rstest]
1973 #[case(BookType::L1_MBP)]
1974 #[case(BookType::L3_MBO)]
1975 fn test_validate_book_depth_rejects_other_book_types(#[case] book_type: BookType) {
1976 let err = validate_book_depth_subscription(book_type).unwrap_err();
1977 assert!(
1978 err.to_string().contains("depth"),
1979 "expected error to cite depth, was: {err}",
1980 );
1981 }
1982
1983 #[rstest]
1984 #[case(LighterMarketStatus::Active, MarketStatusAction::Trading)]
1985 #[case(
1986 LighterMarketStatus::Inactive,
1987 MarketStatusAction::NotAvailableForTrading
1988 )]
1989 fn test_lighter_market_status_action(
1990 #[case] status: LighterMarketStatus,
1991 #[case] expected: MarketStatusAction,
1992 ) {
1993 assert_eq!(lighter_market_status_action(status), expected);
1994 }
1995
1996 #[tokio::test]
1997 async fn test_subscribe_instrument_status_replays_cached_status() {
1998 let (mut client, mut receiver) = create_data_client_with_receiver_for_test();
1999 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2000 cache_lighter_instrument_status(
2001 &client.instrument_statuses,
2002 instrument_id,
2003 LighterMarketStatus::Active,
2004 );
2005
2006 DataClient::subscribe_instrument_status(
2007 &mut client,
2008 SubscribeInstrumentStatus::new(
2009 instrument_id,
2010 Some(ClientId::new("LIGHTER")),
2011 None,
2012 UUID4::new(),
2013 UnixNanos::default(),
2014 None,
2015 None,
2016 ),
2017 )
2018 .unwrap();
2019
2020 let event = receiver.recv().await.expect("instrument status event");
2021 match event {
2022 DataEvent::InstrumentStatus(status) => {
2023 assert_eq!(status.instrument_id, instrument_id);
2024 assert_eq!(status.action, MarketStatusAction::Trading);
2025 assert_eq!(status.is_trading, Some(true));
2026 }
2027 event => panic!("expected instrument status, was {event:?}"),
2028 }
2029 }
2030
2031 #[tokio::test]
2032 async fn test_subscribe_instrument_status_fetches_when_cache_is_empty() {
2033 let base_url = spawn_order_book_details_server().await;
2034 let config = LighterDataClientConfig {
2035 base_url_http: Some(base_url),
2036 ..Default::default()
2037 };
2038 let (mut client, mut receiver) =
2039 create_data_client_with_receiver_and_config_for_test(config);
2040 let instrument_id = client.registry.insert(0, "ETH", LighterProductType::Perp);
2041
2042 DataClient::subscribe_instrument_status(
2043 &mut client,
2044 SubscribeInstrumentStatus::new(
2045 instrument_id,
2046 Some(ClientId::new("LIGHTER")),
2047 None,
2048 UUID4::new(),
2049 UnixNanos::default(),
2050 None,
2051 None,
2052 ),
2053 )
2054 .unwrap();
2055
2056 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2057 .await
2058 .expect("instrument status response")
2059 .expect("instrument status event");
2060
2061 match event {
2062 DataEvent::InstrumentStatus(status) => {
2063 assert_eq!(status.instrument_id, instrument_id);
2064 assert_eq!(status.action, MarketStatusAction::Trading);
2065 assert_eq!(status.is_trading, Some(true));
2066 }
2067 event => panic!("expected instrument status, was {event:?}"),
2068 }
2069 assert!(client.instruments.get_cloned(&instrument_id).is_some());
2070 assert_eq!(
2071 client
2072 .instrument_statuses
2073 .get(&instrument_id)
2074 .map(|status| *status),
2075 Some(LighterMarketStatus::Active),
2076 );
2077 }
2078
2079 #[tokio::test]
2080 async fn test_market_stats_subscriptions_share_perp_channel_until_last_unsub() {
2081 let mut client = create_data_client_for_test();
2082 client.cancellation_token.cancel();
2084 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2085
2086 DataClient::subscribe_mark_prices(
2087 &mut client,
2088 SubscribeMarkPrices::new(
2089 instrument_id,
2090 Some(ClientId::new("LIGHTER")),
2091 None,
2092 UUID4::new(),
2093 UnixNanos::default(),
2094 None,
2095 None,
2096 ),
2097 )
2098 .unwrap();
2099 DataClient::subscribe_index_prices(
2100 &mut client,
2101 SubscribeIndexPrices::new(
2102 instrument_id,
2103 Some(ClientId::new("LIGHTER")),
2104 None,
2105 UUID4::new(),
2106 UnixNanos::default(),
2107 None,
2108 None,
2109 ),
2110 )
2111 .unwrap();
2112 DataClient::subscribe_funding_rates(
2113 &mut client,
2114 SubscribeFundingRates::new(
2115 instrument_id,
2116 Some(ClientId::new("LIGHTER")),
2117 None,
2118 UUID4::new(),
2119 UnixNanos::default(),
2120 None,
2121 None,
2122 ),
2123 )
2124 .unwrap();
2125
2126 let subscription = client
2127 .market_stats_subscriptions
2128 .get(&instrument_id)
2129 .expect("market stats subscription");
2130 assert_eq!(
2131 subscription.flags,
2132 MarketStatsFlags {
2133 mark_price: true,
2134 index_price: true,
2135 funding_rate: true,
2136 },
2137 );
2138 assert!(matches!(
2139 subscription.channel,
2140 LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
2141 ));
2142 drop(subscription);
2143 assert!(
2144 client
2145 .market_stats_subscription_generations
2146 .contains_key(&instrument_id),
2147 );
2148
2149 DataClient::unsubscribe_mark_prices(
2150 &mut client,
2151 &UnsubscribeMarkPrices::new(
2152 instrument_id,
2153 Some(ClientId::new("LIGHTER")),
2154 None,
2155 UUID4::new(),
2156 UnixNanos::default(),
2157 None,
2158 None,
2159 ),
2160 )
2161 .unwrap();
2162 assert_eq!(
2163 client
2164 .market_stats_subscriptions
2165 .get(&instrument_id)
2166 .expect("index and funding still active")
2167 .flags,
2168 MarketStatsFlags {
2169 index_price: true,
2170 funding_rate: true,
2171 ..Default::default()
2172 },
2173 );
2174
2175 DataClient::unsubscribe_index_prices(
2176 &mut client,
2177 &UnsubscribeIndexPrices::new(
2178 instrument_id,
2179 Some(ClientId::new("LIGHTER")),
2180 None,
2181 UUID4::new(),
2182 UnixNanos::default(),
2183 None,
2184 None,
2185 ),
2186 )
2187 .unwrap();
2188 assert_eq!(
2189 client
2190 .market_stats_subscriptions
2191 .get(&instrument_id)
2192 .expect("funding still active")
2193 .flags,
2194 MarketStatsFlags {
2195 funding_rate: true,
2196 ..Default::default()
2197 },
2198 );
2199
2200 DataClient::unsubscribe_funding_rates(
2201 &mut client,
2202 &UnsubscribeFundingRates::new(
2203 instrument_id,
2204 Some(ClientId::new("LIGHTER")),
2205 None,
2206 UUID4::new(),
2207 UnixNanos::default(),
2208 None,
2209 None,
2210 ),
2211 )
2212 .unwrap();
2213 assert!(
2214 !client
2215 .market_stats_subscriptions
2216 .contains_key(&instrument_id)
2217 );
2218 assert!(
2219 !client
2220 .market_stats_subscription_generations
2221 .contains_key(&instrument_id),
2222 );
2223 }
2224
2225 #[rstest]
2226 fn test_market_stats_ws_forwarding_requires_matching_subscription() {
2227 let subscriptions = DashMap::new();
2228 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
2229 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2230 let other_instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), *LIGHTER_VENUE);
2231
2232 subscriptions.insert(
2233 instrument_id,
2234 MarketStatsSubscription {
2235 channel: LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
2236 flags: MarketStatsFlags {
2237 mark_price: true,
2238 index_price: true,
2239 funding_rate: true,
2240 },
2241 },
2242 );
2243
2244 assert!(emit_market_stats_ws_message(
2245 &sender.clone().into(),
2246 &subscriptions,
2247 &NautilusWsMessage::MarkPrice(MarkPriceUpdate::new(
2248 instrument_id,
2249 Price::from("2000.00"),
2250 UnixNanos::from(10),
2251 UnixNanos::from(1),
2252 )),
2253 ));
2254 assert!(emit_market_stats_ws_message(
2255 &sender.clone().into(),
2256 &subscriptions,
2257 &NautilusWsMessage::IndexPrice(IndexPriceUpdate::new(
2258 instrument_id,
2259 Price::from("1999.50"),
2260 UnixNanos::from(11),
2261 UnixNanos::from(1),
2262 )),
2263 ));
2264 assert!(emit_market_stats_ws_message(
2265 &sender.clone().into(),
2266 &subscriptions,
2267 &NautilusWsMessage::FundingRate(FundingRateUpdate::new(
2268 instrument_id,
2269 Decimal::new(12, 6),
2270 None,
2271 Some(UnixNanos::from(100)),
2272 UnixNanos::from(12),
2273 UnixNanos::from(1),
2274 )),
2275 ));
2276
2277 match receiver.try_recv().unwrap() {
2278 DataEvent::Data(Data::MarkPrice(update)) => {
2279 assert_eq!(update.instrument_id, instrument_id);
2280 assert_eq!(update.value, Price::from("2000.00"));
2281 }
2282 event => panic!("expected mark price update, was {event:?}"),
2283 }
2284
2285 match receiver.try_recv().unwrap() {
2286 DataEvent::Data(Data::IndexPrice(update)) => {
2287 assert_eq!(update.instrument_id, instrument_id);
2288 assert_eq!(update.value, Price::from("1999.50"));
2289 }
2290 event => panic!("expected index price update, was {event:?}"),
2291 }
2292
2293 match receiver.try_recv().unwrap() {
2294 DataEvent::FundingRate(update) => {
2295 assert_eq!(update.instrument_id, instrument_id);
2296 assert_eq!(update.rate, Decimal::new(12, 6));
2297 }
2298 event => panic!("expected funding rate update, was {event:?}"),
2299 }
2300
2301 assert!(!emit_market_stats_ws_message(
2302 &sender.into(),
2303 &subscriptions,
2304 &NautilusWsMessage::MarkPrice(MarkPriceUpdate::new(
2305 other_instrument_id,
2306 Price::from("1.00"),
2307 UnixNanos::from(13),
2308 UnixNanos::from(1),
2309 )),
2310 ));
2311 assert!(receiver.try_recv().is_err());
2312 }
2313
2314 #[rstest]
2315 fn test_index_market_stats_channel_uses_spot_stream_for_spot_instrument() {
2316 let client = create_data_client_for_test();
2317 let instrument_id = cache_test_instrument(&client, 2048, "ETH", LighterProductType::Spot);
2318
2319 let channel = client.index_market_stats_channel(instrument_id).unwrap();
2320
2321 assert!(matches!(
2322 channel,
2323 LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(2048)),
2324 ));
2325 }
2326
2327 #[rstest]
2328 fn test_index_market_stats_channel_routes_widened_ids_by_instrument_type() {
2329 let client = create_data_client_for_test();
2330 let perp_id = cache_test_instrument(&client, 40_000, "ETH", LighterProductType::Perp);
2331 let spot_id = cache_test_instrument(&client, 50_000, "ETH", LighterProductType::Spot);
2332
2333 let perp_channel = client.index_market_stats_channel(perp_id).unwrap();
2334 let spot_channel = client.index_market_stats_channel(spot_id).unwrap();
2335
2336 assert!(matches!(
2337 perp_channel,
2338 LighterWsChannel::MarketStats(LighterMarketSelection::Market(40_000)),
2339 ));
2340 assert!(matches!(
2341 spot_channel,
2342 LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(50_000)),
2343 ));
2344 }
2345
2346 #[rstest]
2347 fn test_mark_price_channel_rejects_spot_instrument() {
2348 let client = create_data_client_for_test();
2349 let instrument_id = cache_test_instrument(&client, 2048, "ETH", LighterProductType::Spot);
2350
2351 let err = client
2352 .perp_market_stats_channel(instrument_id, "mark price")
2353 .unwrap_err();
2354
2355 assert!(
2356 err.to_string()
2357 .contains("mark price subscriptions require a perpetual instrument"),
2358 );
2359 }
2360
2361 #[rstest]
2362 fn test_request_bars_rejects_unsupported_bar_type() {
2363 let client = create_data_client_for_test();
2364 let request = RequestBars::new(
2365 unsupported_three_minute_bar_type(),
2366 None,
2367 None,
2368 None,
2369 Some(ClientId::new("LIGHTER")),
2370 UUID4::new(),
2371 UnixNanos::default(),
2372 None,
2373 );
2374
2375 let err = DataClient::request_bars(&client, request).unwrap_err();
2376
2377 assert_eq!(err.to_string(), "unsupported Lighter candle minute step: 3");
2378 }
2379
2380 #[rstest]
2381 fn test_subscribe_bars_rejects_unsupported_bar_type() {
2382 let mut client = create_data_client_for_test();
2383 let subscription = SubscribeBars::new(
2384 unsupported_three_minute_bar_type(),
2385 Some(ClientId::new("LIGHTER")),
2386 None,
2387 UUID4::new(),
2388 UnixNanos::default(),
2389 None,
2390 None,
2391 );
2392
2393 let err = DataClient::subscribe_bars(&mut client, subscription).unwrap_err();
2394
2395 assert_eq!(err.to_string(), "unsupported Lighter candle minute step: 3");
2396 }
2397
2398 #[rstest]
2399 fn test_subscribe_bars_accepts_ws_streamable_resolution() {
2400 let mut client = create_data_client_for_test();
2401 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2402 let bar_type = BarType::new(
2403 instrument_id,
2404 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2405 AggregationSource::External,
2406 );
2407 let subscription = SubscribeBars::new(
2408 bar_type,
2409 Some(ClientId::new("LIGHTER")),
2410 None,
2411 UUID4::new(),
2412 UnixNanos::default(),
2413 None,
2414 None,
2415 );
2416
2417 DataClient::subscribe_bars(&mut client, subscription).unwrap();
2418 }
2419
2420 #[rstest]
2421 fn test_subscribe_bars_missing_cached_instrument_returns_lookup_error() {
2422 let mut client = create_data_client_for_test();
2423 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2424 let bar_type = BarType::new(
2425 instrument_id,
2426 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2427 AggregationSource::External,
2428 );
2429 let subscription = SubscribeBars::new(
2430 bar_type,
2431 Some(ClientId::new("LIGHTER")),
2432 None,
2433 UUID4::new(),
2434 UnixNanos::default(),
2435 None,
2436 None,
2437 );
2438
2439 let err = DataClient::subscribe_bars(&mut client, subscription).unwrap_err();
2440
2441 assert_eq!(
2442 err.to_string(),
2443 InstrumentLookupError::not_found(instrument_id).to_string()
2444 );
2445 }
2446
2447 #[rstest]
2448 fn test_subscribe_bars_rejects_one_week_with_ws_message() {
2449 let mut client = create_data_client_for_test();
2450 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2451 let bar_type = BarType::new(
2452 instrument_id,
2453 BarSpecification::new(1, BarAggregation::Week, PriceType::Last),
2454 AggregationSource::External,
2455 );
2456 let subscription = SubscribeBars::new(
2457 bar_type,
2458 Some(ClientId::new("LIGHTER")),
2459 None,
2460 UUID4::new(),
2461 UnixNanos::default(),
2462 None,
2463 None,
2464 );
2465
2466 let err = DataClient::subscribe_bars(&mut client, subscription).unwrap_err();
2467
2468 assert!(
2469 err.to_string().contains("does not offer")
2470 && err.to_string().contains("candle WebSocket stream"),
2471 "expected WS-streamable rejection, was: {err}",
2472 );
2473 }
2474
2475 #[rstest]
2476 fn test_unsubscribe_bars_returns_ok_for_unsupported_bar_type() {
2477 let mut client = create_data_client_for_test();
2478 let unsubscription = UnsubscribeBars::new(
2479 unsupported_three_minute_bar_type(),
2480 Some(ClientId::new("LIGHTER")),
2481 None,
2482 UUID4::new(),
2483 UnixNanos::default(),
2484 None,
2485 None,
2486 );
2487
2488 DataClient::unsubscribe_bars(&mut client, &unsubscription).unwrap();
2489 }
2490
2491 #[rstest]
2492 fn test_subscribe_book_depth_rejects_unsupported_book_type() {
2493 let mut client = create_data_client_for_test();
2494 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2495 let subscription = SubscribeBookDepth::new(
2496 instrument_id,
2497 BookType::L1_MBP,
2498 Some(ClientId::new("LIGHTER")),
2499 None,
2500 UUID4::new(),
2501 UnixNanos::default(),
2502 None,
2503 false,
2504 None,
2505 None,
2506 );
2507
2508 let err = DataClient::subscribe_book_depth(&mut client, subscription).unwrap_err();
2509
2510 assert!(err.to_string().contains("L2_MBP"));
2511 }
2512
2513 #[rstest]
2514 fn test_request_quotes_rejects_unsupported_rest_quotes() {
2515 let client = create_data_client_for_test();
2516 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2517 let request = RequestQuotes::new(
2518 instrument_id,
2519 None,
2520 None,
2521 None,
2522 Some(ClientId::new("LIGHTER")),
2523 UUID4::new(),
2524 UnixNanos::default(),
2525 None,
2526 );
2527
2528 let err = DataClient::request_quotes(&client, request).unwrap_err();
2529
2530 assert!(
2531 err.to_string()
2532 .contains("does not support historical quote requests"),
2533 );
2534 }
2535
2536 #[rstest]
2537 fn test_request_book_depth_rejects_unsupported_rest_depth() {
2538 let client = create_data_client_for_test();
2539 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2540 let request = RequestBookDepth::new(
2541 instrument_id,
2542 None,
2543 None,
2544 None,
2545 NonZeroUsize::new(10),
2546 Some(ClientId::new("LIGHTER")),
2547 UUID4::new(),
2548 UnixNanos::default(),
2549 None,
2550 );
2551
2552 let err = DataClient::request_book_depth(&client, request).unwrap_err();
2553
2554 assert!(
2555 err.to_string()
2556 .contains("does not support historical order book depth requests"),
2557 );
2558 }
2559
2560 #[rstest]
2561 fn test_request_funding_rates_rejects_spot_instrument() {
2562 let client = create_data_client_for_test();
2563 let instrument_id = cache_test_instrument(&client, 2048, "ETH", LighterProductType::Spot);
2564 let request = RequestFundingRates::new(
2565 instrument_id,
2566 None,
2567 None,
2568 None,
2569 Some(ClientId::new("LIGHTER")),
2570 UUID4::new(),
2571 UnixNanos::default(),
2572 None,
2573 );
2574
2575 let err = DataClient::request_funding_rates(&client, request).unwrap_err();
2576
2577 assert!(
2578 err.to_string()
2579 .contains("funding-rate requests require a perpetual instrument"),
2580 );
2581 }
2582
2583 #[tokio::test]
2584 async fn test_request_funding_rates_emits_response() {
2585 let base_url = spawn_fundings_server().await;
2586 let config = LighterDataClientConfig {
2587 base_url_http: Some(base_url),
2588 ..Default::default()
2589 };
2590 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2591 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2592 let start = Timestamp::from_second(1_778_702_400).unwrap();
2593 let end = Timestamp::from_second(1_778_706_000).unwrap();
2594 let request = RequestFundingRates::new(
2595 instrument_id,
2596 Some(start),
2597 Some(end),
2598 NonZeroUsize::new(2),
2599 Some(ClientId::new("LIGHTER")),
2600 UUID4::new(),
2601 UnixNanos::default(),
2602 None,
2603 );
2604
2605 DataClient::request_funding_rates(&client, request).unwrap();
2606
2607 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2608 .await
2609 .expect("funding rates response")
2610 .expect("funding rates event");
2611
2612 match event {
2613 DataEvent::Response(DataResponse::FundingRates(response)) => {
2614 assert_eq!(response.instrument_id, instrument_id);
2615 assert_eq!(response.data.len(), 2);
2616 assert_eq!(response.data[0].rate, Decimal::new(12, 4));
2617 assert_eq!(response.data[0].interval, Some(60));
2618 assert_eq!(
2619 response.data[0].ts_event,
2620 UnixNanos::from(1_778_702_400_000_000_000)
2621 );
2622 assert_eq!(response.data[1].rate, Decimal::new(-2, 4));
2623 assert_eq!(response.data[1].interval, Some(60));
2624 }
2625 event => panic!("expected funding rates response, was {event:?}"),
2626 }
2627 }
2628
2629 #[tokio::test]
2630 async fn test_request_trades_uses_recent_trades_endpoint() {
2631 let base_url = spawn_trades_server().await;
2632 let config = LighterDataClientConfig {
2633 base_url_http: Some(base_url),
2634 ..Default::default()
2635 };
2636 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2637 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2638 let start = Timestamp::from_second(1_700_000_000).unwrap();
2639 let request = RequestTrades::new(
2640 instrument_id,
2641 Some(start),
2642 None,
2643 NonZeroUsize::new(50),
2644 Some(ClientId::new("LIGHTER")),
2645 UUID4::new(),
2646 UnixNanos::default(),
2647 None,
2648 );
2649
2650 DataClient::request_trades(&client, request).unwrap();
2651
2652 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2653 .await
2654 .expect("trades response")
2655 .expect("trades event");
2656
2657 match event {
2658 DataEvent::Response(DataResponse::Trades(response)) => {
2659 assert_eq!(response.instrument_id, instrument_id);
2660 assert_eq!(response.data.len(), 1);
2661 let tick = &response.data[0];
2662 assert_eq!(tick.instrument_id, instrument_id);
2663 assert_eq!(tick.price, Price::from("2361.31"));
2664 assert_eq!(tick.size, Quantity::from("0.0005"));
2665 assert_eq!(tick.aggressor_side, AggressorSide::Sell);
2666 assert_eq!(tick.trade_id.to_string(), "19211490282");
2667 }
2668 event => panic!("expected trades response, was {event:?}"),
2669 }
2670 }
2671
2672 #[tokio::test]
2673 async fn test_request_trades_clamps_limit_to_venue_cap() {
2674 let base_url = spawn_trades_server_with_response_and_limit(
2675 HTTP_RECENT_TRADES,
2676 LIGHTER_RECENT_TRADES_MAX_LIMIT,
2677 )
2678 .await;
2679 let config = LighterDataClientConfig {
2680 base_url_http: Some(base_url),
2681 ..Default::default()
2682 };
2683 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2684 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2685 let request = RequestTrades::new(
2686 instrument_id,
2687 None,
2688 None,
2689 NonZeroUsize::new(usize::from(LIGHTER_RECENT_TRADES_MAX_LIMIT) + 1),
2690 Some(ClientId::new("LIGHTER")),
2691 UUID4::new(),
2692 UnixNanos::default(),
2693 None,
2694 );
2695
2696 DataClient::request_trades(&client, request).unwrap();
2697
2698 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2699 .await
2700 .expect("trades response")
2701 .expect("trades event");
2702
2703 assert!(
2704 matches!(event, DataEvent::Response(DataResponse::Trades(_))),
2705 "expected trades response, was {event:?}",
2706 );
2707 }
2708
2709 #[tokio::test]
2710 async fn test_request_trades_emits_empty_response_for_null_recent_trades() {
2711 let base_url = spawn_trades_server_with_response(HTTP_RECENT_TRADES_NULL).await;
2712 let config = LighterDataClientConfig {
2713 base_url_http: Some(base_url),
2714 ..Default::default()
2715 };
2716 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2717 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2718 let start = Timestamp::from_second(1_700_000_000).unwrap();
2719 let request = RequestTrades::new(
2720 instrument_id,
2721 Some(start),
2722 None,
2723 NonZeroUsize::new(50),
2724 Some(ClientId::new("LIGHTER")),
2725 UUID4::new(),
2726 UnixNanos::default(),
2727 None,
2728 );
2729
2730 DataClient::request_trades(&client, request).unwrap();
2731
2732 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2733 .await
2734 .expect("trades response")
2735 .expect("trades event");
2736
2737 match event {
2738 DataEvent::Response(DataResponse::Trades(response)) => {
2739 assert_eq!(response.instrument_id, instrument_id);
2740 assert!(response.data.is_empty());
2741 }
2742 event => panic!("expected trades response, was {event:?}"),
2743 }
2744 }
2745
2746 #[tokio::test]
2747 async fn test_request_trades_filters_recent_trades_to_requested_range() {
2748 let base_url = spawn_trades_server().await;
2749 let config = LighterDataClientConfig {
2750 base_url_http: Some(base_url),
2751 ..Default::default()
2752 };
2753 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2754 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2755 let end = Timestamp::from_second(1_700_000_000).unwrap();
2756 let request = RequestTrades::new(
2757 instrument_id,
2758 None,
2759 Some(end),
2760 NonZeroUsize::new(50),
2761 Some(ClientId::new("LIGHTER")),
2762 UUID4::new(),
2763 UnixNanos::default(),
2764 None,
2765 );
2766
2767 DataClient::request_trades(&client, request).unwrap();
2768
2769 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2770 .await
2771 .expect("trades response")
2772 .expect("trades event");
2773
2774 match event {
2775 DataEvent::Response(DataResponse::Trades(response)) => {
2776 assert_eq!(response.instrument_id, instrument_id);
2777 assert!(response.data.is_empty());
2778 }
2779 event => panic!("expected trades response, was {event:?}"),
2780 }
2781 }
2782
2783 #[tokio::test]
2784 async fn test_request_trades_returns_recent_trades_in_timestamp_order() {
2785 let base_url = spawn_trades_server_with_response(HTTP_RECENT_TRADES_UNORDERED).await;
2786 let config = LighterDataClientConfig {
2787 base_url_http: Some(base_url),
2788 ..Default::default()
2789 };
2790 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2791 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2792 let start = Timestamp::from_millisecond(1_777_945_103_092).unwrap();
2793 let end = Timestamp::from_millisecond(1_777_945_103_094).unwrap();
2794 let request = RequestTrades::new(
2795 instrument_id,
2796 Some(start),
2797 Some(end),
2798 NonZeroUsize::new(50),
2799 Some(ClientId::new("LIGHTER")),
2800 UUID4::new(),
2801 UnixNanos::default(),
2802 None,
2803 );
2804
2805 DataClient::request_trades(&client, request).unwrap();
2806
2807 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2808 .await
2809 .expect("trades response")
2810 .expect("trades event");
2811
2812 match event {
2813 DataEvent::Response(DataResponse::Trades(response)) => {
2814 assert_eq!(response.instrument_id, instrument_id);
2815 assert_eq!(
2816 response
2817 .data
2818 .iter()
2819 .map(|trade| trade.trade_id.to_string())
2820 .collect::<Vec<_>>(),
2821 vec!["19211490282", "19211490283", "19211490284"],
2822 );
2823 assert_eq!(
2824 response
2825 .data
2826 .iter()
2827 .map(|trade| trade.ts_event.as_u64())
2828 .collect::<Vec<_>>(),
2829 vec![
2830 1_777_945_103_092_000_000,
2831 1_777_945_103_093_000_000,
2832 1_777_945_103_094_000_000,
2833 ],
2834 );
2835 }
2836 event => panic!("expected trades response, was {event:?}"),
2837 }
2838 }
2839
2840 #[rstest]
2841 fn test_retain_trade_ticks_in_range_sorts_ascending() {
2842 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2843 let tick = |ts_event, trade_id| {
2844 TradeTick::new(
2845 instrument_id,
2846 Price::from("1.0"),
2847 Quantity::from("1.0"),
2848 AggressorSide::Buy,
2849 TradeId::new(trade_id),
2850 UnixNanos::from(ts_event),
2851 UnixNanos::from(ts_event + 1),
2852 )
2853 };
2854 let mut trades = vec![tick(4, "4"), tick(1, "1"), tick(3, "3"), tick(2, "2")];
2855
2856 retain_trade_ticks_in_range(
2857 &mut trades,
2858 Some(UnixNanos::from(2)),
2859 Some(UnixNanos::from(4)),
2860 );
2861
2862 assert_eq!(
2863 trades
2864 .iter()
2865 .map(|trade| trade.ts_event.as_u64())
2866 .collect::<Vec<_>>(),
2867 vec![2, 3, 4],
2868 );
2869 }
2870
2871 #[tokio::test]
2872 async fn test_spawn_instrument_refresh_skipped_when_interval_zero() {
2873 let config = LighterDataClientConfig {
2874 update_instruments_interval_mins: 0,
2875 ..Default::default()
2876 };
2877 let (client, _receiver) = create_data_client_with_receiver_and_config_for_test(config);
2878
2879 assert!(client.tasks.is_empty());
2880 client
2881 .spawn_instrument_refresh()
2882 .expect("instrument refresh remains disabled");
2883 assert!(client.tasks.is_empty());
2884 }
2885
2886 #[tokio::test]
2887 async fn test_spawn_instrument_refresh_registers_task() {
2888 let config = LighterDataClientConfig {
2889 update_instruments_interval_mins: 60,
2890 ..Default::default()
2891 };
2892 let (mut client, _receiver) = create_data_client_with_receiver_and_config_for_test(config);
2893
2894 assert!(client.tasks.is_empty());
2895 client
2896 .spawn_instrument_refresh()
2897 .expect("instrument refresh task registration");
2898 assert_eq!(client.tasks.len(), 1);
2899
2900 client.tasks.begin_shutdown();
2901 client.shutdown_tasks().await.expect("task shutdown");
2902 }
2903
2904 #[tokio::test]
2905 async fn test_await_instrument_refresh_drops_result_when_request_cancels() {
2906 let cancellation = CancellationToken::new();
2907 let request_cancellation = cancellation.clone();
2908
2909 let result = await_instrument_refresh(&cancellation, async move {
2910 request_cancellation.cancel();
2911 42
2912 })
2913 .await;
2914
2915 assert_eq!(result, None);
2916 }
2917
2918 #[tokio::test]
2919 async fn test_reset_closes_registered_task_generation_until_drain() {
2920 let (mut client, _receiver) = create_data_client_with_receiver_for_test();
2921 let old_token = client.cancellation_token.clone();
2922 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2923 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2924
2925 client
2926 .tasks
2927 .spawn(async move {
2928 let _drop_signal = DropSignal(Some(dropped_tx));
2929 let _ = started_tx.send(());
2930 std::future::pending::<()>().await;
2931 })
2932 .expect("registered task spawn");
2933 started_rx.await.expect("registered task started");
2934
2935 client.reset().expect("reset");
2936
2937 assert!(old_token.is_cancelled());
2938 assert_eq!(client.tasks.len(), 1);
2939 assert!(!client.tasks.is_open());
2940 assert!(client.cancellation_token.is_cancelled());
2941 client.shutdown_tasks().await.expect("reset task shutdown");
2942 assert!(client.tasks.is_empty());
2943 tokio::time::timeout(Duration::from_secs(2), dropped_rx)
2944 .await
2945 .expect("registered task was not aborted")
2946 .expect("drop signal sender dropped");
2947 }
2948
2949 #[tokio::test]
2950 async fn test_spawn_task_suppresses_output_after_cancellation() {
2951 let (client, mut receiver) = create_data_client_with_receiver_for_test();
2952 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2953 let instrument = client
2954 .instruments
2955 .get_cloned(&instrument_id)
2956 .expect("cached instrument");
2957
2958 client.cancellation_token.cancel();
2960
2961 let sender = client.data_sender.clone();
2962 client.spawn_task(async move {
2963 let _ = sender.send(DataEvent::Instrument(instrument));
2964 });
2965
2966 let result = tokio::time::timeout(Duration::from_millis(200), receiver.recv()).await;
2967 assert!(
2968 result.is_err(),
2969 "expected no DataEvent after cancellation, was {result:?}",
2970 );
2971 }
2972
2973 #[tokio::test]
2974 async fn test_connect_is_idempotent_when_already_connected() {
2975 let (mut client, _receiver) = create_data_client_with_receiver_for_test();
2976 client.is_connected.store(true, Ordering::Release);
2977
2978 client
2979 .connect()
2980 .await
2981 .expect("connect returns Ok when already connected");
2982
2983 assert!(
2984 client.tasks.is_empty(),
2985 "an already-connected client must not spawn duplicate tasks",
2986 );
2987 assert!(client.is_connected());
2988 }
2989
2990 #[tokio::test]
2991 async fn test_disconnect_drains_in_flight_task_and_suppresses_late_event() {
2992 let (mut client, mut receiver) = create_data_client_with_receiver_for_test();
2993 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2994 let instrument = client
2995 .instruments
2996 .get_cloned(&instrument_id)
2997 .expect("cached instrument");
2998 client.is_connected.store(true, Ordering::Release);
2999
3000 let sender = client.data_sender.clone();
3002 let (hold_tx, hold_rx) = tokio::sync::oneshot::channel::<()>();
3003 client.spawn_task(async move {
3004 let _ = hold_rx.await;
3005 let _ = sender.send(DataEvent::Instrument(instrument));
3006 });
3007 assert_eq!(client.tasks.len(), 1);
3008
3009 client.disconnect().await.expect("disconnect");
3010
3011 assert!(
3012 client.tasks.is_empty(),
3013 "disconnect must drain tracked tasks",
3014 );
3015 assert!(!client.is_connected());
3016 let result = tokio::time::timeout(Duration::from_millis(200), receiver.recv()).await;
3017 assert!(
3018 result.is_err(),
3019 "expected no DataEvent after disconnect, was {result:?}",
3020 );
3021
3022 drop(hold_tx);
3023 }
3024
3025 #[tokio::test]
3026 async fn test_disconnect_aborts_task_that_ignores_cancellation() {
3027 let (mut client, _receiver) = create_data_client_with_receiver_for_test();
3028 client.is_connected.store(true, Ordering::Release);
3029
3030 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3031 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
3032
3033 client
3035 .tasks
3036 .spawn(async move {
3037 let _drop_signal = DropSignal(Some(dropped_tx));
3038 let _ = started_tx.send(());
3039 std::future::pending::<()>().await;
3040 })
3041 .expect("uncancellable task spawn");
3042 started_rx.await.expect("task started");
3043
3044 client.disconnect().await.expect("disconnect");
3045
3046 assert!(client.tasks.is_empty());
3047 assert!(!client.is_connected());
3048 tokio::time::timeout(Duration::from_secs(5), dropped_rx)
3049 .await
3050 .expect("task aborted after timeout")
3051 .expect("drop signal sender dropped");
3052 }
3053
3054 #[rstest]
3055 fn test_rollback_market_stats_subscription_clears_piggybacked_flags() {
3056 let subscriptions = DashMap::new();
3057 let generations = DashMap::new();
3058 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
3059 subscriptions.insert(
3060 instrument_id,
3061 MarketStatsSubscription {
3062 channel: LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
3063 flags: MarketStatsFlags {
3064 mark_price: true,
3065 index_price: true,
3066 ..Default::default()
3067 },
3068 },
3069 );
3070 generations.insert(instrument_id, 7);
3071
3072 rollback_market_stats_subscription(&subscriptions, &generations, instrument_id, 7);
3073
3074 assert!(
3075 !subscriptions.contains_key(&instrument_id),
3076 "all flags share the failed underlying channel",
3077 );
3078 assert!(!generations.contains_key(&instrument_id));
3079 }
3080
3081 #[rstest]
3082 fn test_rollback_market_stats_subscription_keeps_replacement_generation() {
3083 let subscriptions = DashMap::new();
3084 let generations = DashMap::new();
3085 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
3086 let replacement = MarketStatsSubscription {
3087 channel: LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
3088 flags: MarketStatsFlags {
3089 funding_rate: true,
3090 ..Default::default()
3091 },
3092 };
3093 subscriptions.insert(instrument_id, replacement.clone());
3094 generations.insert(instrument_id, 8);
3095
3096 rollback_market_stats_subscription(&subscriptions, &generations, instrument_id, 7);
3097
3098 assert_eq!(
3099 subscriptions
3100 .get(&instrument_id)
3101 .expect("replacement retained")
3102 .flags,
3103 replacement.flags,
3104 );
3105 assert_eq!(generations.get(&instrument_id).map(|value| *value), Some(8));
3106 }
3107
3108 fn create_data_client_for_test() -> LighterDataClient {
3109 create_data_client_with_receiver_for_test().0
3110 }
3111
3112 fn create_data_client_with_receiver_for_test() -> (
3113 LighterDataClient,
3114 tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
3115 ) {
3116 create_data_client_with_receiver_and_config_for_test(LighterDataClientConfig::default())
3117 }
3118
3119 fn create_data_client_with_receiver_and_config_for_test(
3120 mut config: LighterDataClientConfig,
3121 ) -> (
3122 LighterDataClient,
3123 tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
3124 ) {
3125 config.api_key_index = Some(5);
3126 config.account_index = Some(12_345);
3127 config.private_key = Some(PRIVATE_KEY_HEX.into());
3128 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
3129 replace_data_event_sender(sender);
3130 let client = LighterDataClient::new(ClientId::new("LIGHTER"), config).unwrap();
3131 (client, receiver)
3132 }
3133
3134 async fn spawn_order_book_details_server() -> String {
3135 let app = Router::new().route("/api/v1/orderBookDetails", get(order_book_details));
3136 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3137 let addr = listener.local_addr().unwrap();
3138 tokio::spawn(async move {
3139 axum::serve(listener, app).await.unwrap();
3140 });
3141
3142 format!("http://{addr}")
3143 }
3144
3145 async fn spawn_fundings_server() -> String {
3146 let app = Router::new().route("/api/v1/fundings", get(fundings));
3147 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3148 let addr = listener.local_addr().unwrap();
3149 tokio::spawn(async move {
3150 axum::serve(listener, app).await.unwrap();
3151 });
3152
3153 format!("http://{addr}")
3154 }
3155
3156 async fn spawn_trades_server() -> String {
3157 spawn_trades_server_with_response(HTTP_RECENT_TRADES).await
3158 }
3159
3160 async fn spawn_trades_server_with_response(response_body: &'static str) -> String {
3161 spawn_trades_server_with_response_and_limit(response_body, 50).await
3162 }
3163
3164 async fn spawn_trades_server_with_response_and_limit(
3165 response_body: &'static str,
3166 expected_limit: u16,
3167 ) -> String {
3168 let app = Router::new().route(
3169 "/api/v1/recentTrades",
3170 get(
3171 move |Query(query): Query<LighterRecentTradesQuery>| async move {
3172 recent_trades_response(&query, response_body, expected_limit)
3173 },
3174 ),
3175 );
3176 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3177 let addr = listener.local_addr().unwrap();
3178 tokio::spawn(async move {
3179 axum::serve(listener, app).await.unwrap();
3180 });
3181
3182 format!("http://{addr}")
3183 }
3184
3185 async fn order_book_details() -> Response {
3186 (StatusCode::OK, HTTP_ORDER_BOOK_DETAILS).into_response()
3187 }
3188
3189 async fn fundings(Query(query): Query<LighterFundingsQuery>) -> Response {
3190 assert_eq!(query.market_id, 0);
3191 assert_eq!(query.resolution, LighterFundingResolution::OneHour);
3192 assert_eq!(query.start_timestamp, 1_778_702_400_000);
3193 assert_eq!(query.end_timestamp, 1_778_706_000_000);
3194 assert_eq!(
3195 query.count_back,
3196 i64::from(crate::http::client::LIGHTER_FUNDINGS_MAX_LIMIT)
3197 );
3198 (StatusCode::OK, HTTP_FUNDINGS).into_response()
3199 }
3200
3201 fn recent_trades_response(
3202 query: &LighterRecentTradesQuery,
3203 response_body: &'static str,
3204 expected_limit: u16,
3205 ) -> Response {
3206 assert_eq!(query.market_id, 0);
3207 assert_eq!(query.limit, expected_limit);
3208 (StatusCode::OK, response_body).into_response()
3209 }
3210
3211 fn cache_test_instrument(
3212 client: &LighterDataClient,
3213 market_index: i64,
3214 venue_symbol: &str,
3215 product_type: LighterProductType,
3216 ) -> InstrumentId {
3217 let instrument_id = client
3218 .registry
3219 .insert(market_index, venue_symbol, product_type);
3220 let instrument = match product_type {
3221 LighterProductType::Perp => test_perp_instrument(instrument_id, venue_symbol),
3222 LighterProductType::Spot => test_spot_instrument(instrument_id, venue_symbol),
3223 };
3224
3225 client.instruments.rcu(|m| {
3226 m.insert(instrument_id, instrument.clone());
3227 });
3228
3229 instrument_id
3230 }
3231
3232 fn test_perp_instrument(instrument_id: InstrumentId, venue_symbol: &str) -> InstrumentAny {
3233 InstrumentAny::CryptoPerpetual(
3234 CryptoPerpetual::builder()
3235 .instrument_id(instrument_id)
3236 .raw_symbol(Symbol::new(format!("{venue_symbol}-PERP")))
3237 .base_currency(Currency::from(venue_symbol))
3238 .quote_currency(Currency::from("USDC"))
3239 .settlement_currency(Currency::from("USDC"))
3240 .is_inverse(false)
3241 .price_precision(2)
3242 .size_precision(4)
3243 .price_increment(Price::from("0.01"))
3244 .size_increment(Quantity::from("0.0001"))
3245 .ts_event(UnixNanos::default())
3246 .ts_init(UnixNanos::default())
3247 .build()
3248 .unwrap(),
3249 )
3250 }
3251
3252 fn test_spot_instrument(instrument_id: InstrumentId, venue_symbol: &str) -> InstrumentAny {
3253 InstrumentAny::CurrencyPair(
3254 CurrencyPair::builder()
3255 .instrument_id(instrument_id)
3256 .raw_symbol(Symbol::new(format!("{venue_symbol}-SPOT")))
3257 .base_currency(Currency::from(venue_symbol))
3258 .quote_currency(Currency::from("USDC"))
3259 .price_precision(2)
3260 .size_precision(4)
3261 .price_increment(Price::from("0.01"))
3262 .size_increment(Quantity::from("0.0001"))
3263 .ts_event(UnixNanos::default())
3264 .ts_init(UnixNanos::default())
3265 .build()
3266 .unwrap(),
3267 )
3268 }
3269
3270 fn unsupported_three_minute_bar_type() -> BarType {
3271 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
3272 BarType::new(
3273 instrument_id,
3274 BarSpecification::new(3, BarAggregation::Minute, PriceType::Last),
3275 AggregationSource::External,
3276 )
3277 }
3278}