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, execute_bar_subscriptions, execute_instrument_subscriptions},
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::Data(instrument.clone().into()))
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::Data(Data::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::Data(instrument.into())) {
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 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
1076 let instruments = self.instruments.load();
1077 if let Some(instrument) = instruments.get(&instrument_id) {
1078 if let Err(e) = self
1079 .data_sender
1080 .send(DataEvent::Data(instrument.clone().into()))
1081 {
1082 log::error!("Failed to send instrument {instrument_id}: {e}");
1083 }
1084 } else {
1085 log::warn!("Instrument {instrument_id} not found in cache");
1086 }
1087
1088 Ok(())
1089 })
1090 }
1091
1092 fn unsubscribe_instrument(&mut self, cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
1093 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
1094 log::debug!("Unsubscribing from instrument: {instrument_id} (cache replay only)",);
1095 Ok(())
1096 })
1097 }
1098
1099 fn subscribe_instrument_status(
1100 &mut self,
1101 subscription: SubscribeInstrumentStatus,
1102 ) -> anyhow::Result<()> {
1103 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1104 self.instrument_status_subscriptions.insert(instrument_id);
1105 if self.emit_cached_instrument_status(instrument_id) {
1106 return Ok(());
1107 }
1108
1109 let http = self.http_client.clone();
1110 let ws = self.ws_client.clone();
1111 let registry = Arc::clone(&self.registry);
1112 let sender = self.data_sender.clone();
1113 let instruments_cache = Arc::clone(&self.instruments);
1114 let statuses = Arc::clone(&self.instrument_statuses);
1115 let subscriptions = Arc::clone(&self.instrument_status_subscriptions);
1116 let clock = self.clock;
1117
1118 self.spawn_task(async move {
1119 match http.request_instrument_with_status(instrument_id).await {
1120 Ok((instrument, status)) => {
1121 instruments_cache.rcu(|map| {
1122 map.insert(instrument.id(), instrument.clone());
1123 });
1124
1125 if let Some(market_index) = registry.market_index(&instrument.id()) {
1126 ws.cache_instrument(market_index, instrument.clone());
1127 }
1128
1129 cache_lighter_instrument_status(&statuses, instrument.id(), status);
1130 let ts_init = clock.get_time_ns();
1131 emit_lighter_instrument_status_if_subscribed(
1132 &sender,
1133 &subscriptions,
1134 instrument.id(),
1135 status,
1136 ts_init,
1137 ts_init,
1138 );
1139 }
1140 Err(e) => {
1141 log::error!(
1142 "Failed to fetch Lighter instrument status for {instrument_id}: {e:?}"
1143 );
1144 }
1145 }
1146 });
1147
1148 Ok(())
1149 })
1150 }
1151
1152 fn subscribe_book_deltas(&mut self, subscription: SubscribeBookDeltas) -> anyhow::Result<()> {
1153 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1154 let subscription = subscription.with_instrument_ids(instrument_id);
1155 validate_book_deltas_subscription(subscription.book_type)?;
1156
1157 let ws = self.ws_client.clone();
1158 self.spawn_task(async move {
1159 if let Err(e) = ws.subscribe_book(instrument_id).await {
1160 log::error!("Failed to subscribe to Lighter book deltas: {e:?}");
1161 }
1162 });
1163
1164 Ok(())
1165 })
1166 }
1167
1168 fn subscribe_book_depth(&mut self, subscription: SubscribeBookDepth) -> anyhow::Result<()> {
1169 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1170 let subscription = subscription.with_instrument_ids(instrument_id);
1171 log::debug!("Subscribing to book depth: {instrument_id}");
1172
1173 validate_book_depth_subscription(subscription.book_type)?;
1174
1175 let ws = self.ws_client.clone();
1176 self.spawn_task(async move {
1177 if let Err(e) = ws.subscribe_book_depth(instrument_id).await {
1178 log::error!("Failed to subscribe to Lighter book depth: {e:?}");
1179 }
1180 });
1181
1182 Ok(())
1183 })
1184 }
1185
1186 fn subscribe_quotes(&mut self, subscription: SubscribeQuotes) -> anyhow::Result<()> {
1187 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1188 let ws = self.ws_client.clone();
1189 self.spawn_task(async move {
1190 if let Err(e) = ws.subscribe_quotes(instrument_id).await {
1191 log::error!("Failed to subscribe to Lighter quotes: {e:?}");
1192 }
1193 });
1194
1195 Ok(())
1196 })
1197 }
1198
1199 fn subscribe_trades(&mut self, subscription: SubscribeTrades) -> anyhow::Result<()> {
1200 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1201 let ws = self.ws_client.clone();
1202 self.spawn_task(async move {
1203 if let Err(e) = ws.subscribe_trades(instrument_id).await {
1204 log::error!("Failed to subscribe to Lighter trades: {e:?}");
1205 }
1206 });
1207
1208 Ok(())
1209 })
1210 }
1211
1212 fn subscribe_mark_prices(&mut self, subscription: SubscribeMarkPrices) -> anyhow::Result<()> {
1213 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1214 let channel = self.perp_market_stats_channel(instrument_id, "mark price")?;
1215 self.activate_market_stats_subscription(
1216 instrument_id,
1217 channel,
1218 MarketStatsKind::MarkPrice,
1219 "mark price",
1220 );
1221
1222 Ok(())
1223 })
1224 }
1225
1226 fn subscribe_index_prices(&mut self, subscription: SubscribeIndexPrices) -> anyhow::Result<()> {
1227 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1228 let channel = self.index_market_stats_channel(instrument_id)?;
1229 self.activate_market_stats_subscription(
1230 instrument_id,
1231 channel,
1232 MarketStatsKind::IndexPrice,
1233 "index price",
1234 );
1235
1236 Ok(())
1237 })
1238 }
1239
1240 fn subscribe_funding_rates(
1241 &mut self,
1242 subscription: SubscribeFundingRates,
1243 ) -> anyhow::Result<()> {
1244 execute_instrument_subscriptions(&subscription.instrument_ids, |instrument_id| {
1245 let channel = self.perp_market_stats_channel(instrument_id, "funding rate")?;
1246 self.activate_market_stats_subscription(
1247 instrument_id,
1248 channel,
1249 MarketStatsKind::FundingRate,
1250 "funding rate",
1251 );
1252
1253 Ok(())
1254 })
1255 }
1256
1257 fn subscribe_bars(&mut self, subscription: SubscribeBars) -> anyhow::Result<()> {
1258 execute_bar_subscriptions(&subscription.bar_types, |bar_type| {
1259 let resolution = LighterCandleResolution::try_from(&bar_type)?;
1260 anyhow::ensure!(
1261 resolution.is_ws_streamable(),
1262 "Lighter does not offer {bar_type} on the candle WebSocket stream",
1263 );
1264
1265 let instrument_id = bar_type.instrument_id();
1266 if !self.instruments.contains_key(&instrument_id) {
1267 return Err(InstrumentLookupError::not_found(instrument_id).into());
1268 }
1269
1270 let ws = self.ws_client.clone();
1271 self.spawn_task(async move {
1272 if let Err(e) = ws.subscribe_candles(instrument_id, resolution).await {
1273 log::error!("Failed to subscribe to Lighter candles for {bar_type}: {e:?}");
1274 }
1275 });
1276
1277 Ok(())
1278 })
1279 }
1280
1281 fn unsubscribe_book_deltas(
1282 &mut self,
1283 unsubscription: &UnsubscribeBookDeltas,
1284 ) -> anyhow::Result<()> {
1285 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1286 log::debug!("Unsubscribing from book deltas: {instrument_id}");
1287
1288 let ws = self.ws_client.clone();
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
1299 fn unsubscribe_book_depth(
1300 &mut self,
1301 unsubscription: &UnsubscribeBookDepth,
1302 ) -> anyhow::Result<()> {
1303 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1304 log::debug!("Unsubscribing from book depth: {instrument_id}");
1305
1306 let ws = self.ws_client.clone();
1307 self.spawn_task(async move {
1308 if let Err(e) = ws.unsubscribe_book_depth(instrument_id).await {
1309 log::error!("Failed to unsubscribe from Lighter book depth: {e:?}");
1310 }
1311 });
1312
1313 Ok(())
1314 })
1315 }
1316
1317 fn unsubscribe_quotes(&mut self, unsubscription: &UnsubscribeQuotes) -> anyhow::Result<()> {
1318 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1319 log::debug!("Unsubscribing from quotes: {instrument_id}");
1320
1321 let ws = self.ws_client.clone();
1322 self.spawn_task(async move {
1323 if let Err(e) = ws.unsubscribe_quotes(instrument_id).await {
1324 log::error!("Failed to unsubscribe from Lighter quotes: {e:?}");
1325 }
1326 });
1327
1328 Ok(())
1329 })
1330 }
1331
1332 fn unsubscribe_trades(&mut self, unsubscription: &UnsubscribeTrades) -> anyhow::Result<()> {
1333 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1334 log::debug!("Unsubscribing from trades: {instrument_id}");
1335
1336 let ws = self.ws_client.clone();
1337 self.spawn_task(async move {
1338 if let Err(e) = ws.unsubscribe_trades(instrument_id).await {
1339 log::error!("Failed to unsubscribe from Lighter trades: {e:?}");
1340 }
1341 });
1342
1343 Ok(())
1344 })
1345 }
1346
1347 fn unsubscribe_instrument_status(
1348 &mut self,
1349 unsubscription: &UnsubscribeInstrumentStatus,
1350 ) -> anyhow::Result<()> {
1351 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1352 self.instrument_status_subscriptions.remove(&instrument_id);
1353
1354 Ok(())
1355 })
1356 }
1357
1358 fn unsubscribe_mark_prices(
1359 &mut self,
1360 unsubscription: &UnsubscribeMarkPrices,
1361 ) -> anyhow::Result<()> {
1362 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1363 self.deactivate_market_stats_subscription(
1364 instrument_id,
1365 MarketStatsKind::MarkPrice,
1366 "mark price",
1367 );
1368
1369 Ok(())
1370 })
1371 }
1372
1373 fn unsubscribe_index_prices(
1374 &mut self,
1375 unsubscription: &UnsubscribeIndexPrices,
1376 ) -> anyhow::Result<()> {
1377 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1378 self.deactivate_market_stats_subscription(
1379 instrument_id,
1380 MarketStatsKind::IndexPrice,
1381 "index price",
1382 );
1383
1384 Ok(())
1385 })
1386 }
1387
1388 fn unsubscribe_funding_rates(
1389 &mut self,
1390 unsubscription: &UnsubscribeFundingRates,
1391 ) -> anyhow::Result<()> {
1392 execute_instrument_subscriptions(&unsubscription.instrument_ids, |instrument_id| {
1393 self.deactivate_market_stats_subscription(
1394 instrument_id,
1395 MarketStatsKind::FundingRate,
1396 "funding rate",
1397 );
1398
1399 Ok(())
1400 })
1401 }
1402
1403 fn unsubscribe_bars(&mut self, unsubscription: &UnsubscribeBars) -> anyhow::Result<()> {
1404 execute_bar_subscriptions(&unsubscription.bar_types, |bar_type| {
1405 let resolution = match LighterCandleResolution::try_from(&bar_type) {
1406 Ok(resolution) => resolution,
1407 Err(e) => {
1408 log::warn!("Skipping Lighter candle unsubscribe for {bar_type}: {e}");
1409 return Ok(());
1410 }
1411 };
1412
1413 let instrument_id = bar_type.instrument_id();
1414 let ws = self.ws_client.clone();
1415 self.spawn_task(async move {
1416 if let Err(e) = ws.unsubscribe_candles(instrument_id, resolution).await {
1417 log::error!("Failed to unsubscribe from Lighter candles for {bar_type}: {e:?}");
1418 }
1419 });
1420
1421 Ok(())
1422 })
1423 }
1424
1425 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
1426 log::debug!("Requesting Lighter instruments");
1427
1428 let http = self.http_client.clone();
1429 let ws = self.ws_client.clone();
1430 let registry = Arc::clone(&self.registry);
1431 let sender = self.data_sender.clone();
1432 let instruments_cache = Arc::clone(&self.instruments);
1433 let status_cache = Arc::clone(&self.instrument_statuses);
1434 let status_subscriptions = Arc::clone(&self.instrument_status_subscriptions);
1435 let request_id = request.request_id;
1436 let client_id = request.client_id.unwrap_or(self.client_id);
1437 let venue = self.venue();
1438 let start_nanos = datetime_to_unix_nanos(request.start);
1439 let end_nanos = datetime_to_unix_nanos(request.end);
1440 let params = request.params;
1441 let clock = self.clock;
1442
1443 self.spawn_task(async move {
1444 match http.request_instruments_with_status().await {
1445 Ok(instruments_with_status) => {
1446 let instruments: Vec<InstrumentAny> = instruments_with_status
1447 .iter()
1448 .map(|(instrument, _)| instrument.clone())
1449 .collect();
1450
1451 instruments_cache.rcu(|map| {
1452 for instrument in &instruments {
1453 map.insert(instrument.id(), instrument.clone());
1454 }
1455 });
1456
1457 let ws_cache: Vec<(i64, InstrumentAny)> = instruments
1458 .iter()
1459 .filter_map(|i| registry.market_index(&i.id()).map(|idx| (idx, i.clone())))
1460 .collect();
1461
1462 if !ws_cache.is_empty() {
1463 ws.cache_instruments(ws_cache);
1464 }
1465
1466 status_cache.clear();
1467 let ts_init = clock.get_time_ns();
1468
1469 for (instrument, status) in &instruments_with_status {
1470 cache_lighter_instrument_status(&status_cache, instrument.id(), *status);
1471 emit_lighter_instrument_status_if_subscribed(
1472 &sender,
1473 &status_subscriptions,
1474 instrument.id(),
1475 *status,
1476 ts_init,
1477 ts_init,
1478 );
1479 }
1480
1481 let response = DataResponse::Instruments(InstrumentsResponse::new(
1482 request_id,
1483 client_id,
1484 venue,
1485 instruments,
1486 start_nanos,
1487 end_nanos,
1488 clock.get_time_ns(),
1489 params,
1490 ));
1491
1492 if let Err(e) = sender.send(DataEvent::Response(response)) {
1493 log::error!("Failed to send instruments response: {e}");
1494 }
1495 }
1496 Err(e) => {
1497 log::error!("Failed to fetch Lighter instruments: {e:?}");
1498 }
1499 }
1500 });
1501
1502 Ok(())
1503 }
1504
1505 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
1506 let instrument_id = request.single_instrument_id()?;
1507
1508 log::debug!("Requesting Lighter instrument: {instrument_id}");
1509
1510 let http = self.http_client.clone();
1511 let ws = self.ws_client.clone();
1512 let registry = Arc::clone(&self.registry);
1513 let sender = self.data_sender.clone();
1514 let instruments_cache = Arc::clone(&self.instruments);
1515 let status_cache = Arc::clone(&self.instrument_statuses);
1516 let status_subscriptions = Arc::clone(&self.instrument_status_subscriptions);
1517 let request_id = request.request_id;
1518 let client_id = request.client_id.unwrap_or(self.client_id);
1519 let start_nanos = datetime_to_unix_nanos(request.start);
1520 let end_nanos = datetime_to_unix_nanos(request.end);
1521 let params = request.params;
1522 let clock = self.clock;
1523
1524 self.spawn_task(async move {
1525 match http.request_instrument_with_status(instrument_id).await {
1526 Ok((instrument, status)) => {
1527 instruments_cache.rcu(|map| {
1528 map.insert(instrument.id(), instrument.clone());
1529 });
1530
1531 if let Some(market_index) = registry.market_index(&instrument.id()) {
1532 ws.cache_instrument(market_index, instrument.clone());
1533 }
1534
1535 cache_lighter_instrument_status(&status_cache, instrument.id(), status);
1536 let ts_init = clock.get_time_ns();
1537 emit_lighter_instrument_status_if_subscribed(
1538 &sender,
1539 &status_subscriptions,
1540 instrument.id(),
1541 status,
1542 ts_init,
1543 ts_init,
1544 );
1545
1546 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
1547 request_id,
1548 client_id,
1549 instrument.id(),
1550 instrument,
1551 start_nanos,
1552 end_nanos,
1553 clock.get_time_ns(),
1554 params,
1555 )));
1556
1557 if let Err(e) = sender.send(DataEvent::Response(response)) {
1558 log::error!("Failed to send instrument response: {e}");
1559 }
1560 }
1561 Err(e) => {
1562 log::error!("Failed to fetch Lighter instrument {instrument_id}: {e:?}");
1563 }
1564 }
1565 });
1566
1567 Ok(())
1568 }
1569
1570 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1571 let bar_type = request.single_bar_type()?;
1572
1573 log::debug!("Requesting Lighter bars for {bar_type}");
1574
1575 LighterCandleResolution::try_from(&bar_type)?;
1576
1577 let instrument_id = bar_type.instrument_id();
1578 let instrument = self
1579 .instruments
1580 .get_cloned(&instrument_id)
1581 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1582
1583 let http = self.http_client.clone();
1584 let sender = self.data_sender.clone();
1585 let start = request.start;
1586 let end = request.end;
1587 let limit = request.limit.map(|n| n.get() as u32);
1588 let request_id = request.request_id;
1589 let client_id = request.client_id.unwrap_or(self.client_id);
1590 let params = request.params;
1591 let clock = self.clock;
1592 let start_nanos = datetime_to_unix_nanos(start);
1593 let end_nanos = datetime_to_unix_nanos(end);
1594
1595 self.spawn_task(async move {
1596 match http
1597 .request_bars(&instrument, bar_type, start, end, limit)
1598 .await
1599 {
1600 Ok(bars) => {
1601 let response = DataResponse::Bars(BarsResponse::new(
1602 request_id,
1603 client_id,
1604 bar_type,
1605 bars,
1606 start_nanos,
1607 end_nanos,
1608 clock.get_time_ns(),
1609 params,
1610 ));
1611
1612 if let Err(e) = sender.send(DataEvent::Response(response)) {
1613 log::error!("Failed to send bars response: {e}");
1614 }
1615 }
1616 Err(e) => {
1617 log::error!("Lighter bars request failed for {instrument_id}: {e:?}");
1618 }
1619 }
1620 });
1621
1622 Ok(())
1623 }
1624
1625 fn request_quotes(&self, request: RequestQuotes) -> anyhow::Result<()> {
1626 let instrument_id = request.single_instrument_id()?;
1627
1628 anyhow::bail!(
1629 "Lighter does not support historical quote requests for {instrument_id}; \
1630 subscribe to quotes via WebSocket for live BBO ticks"
1631 )
1632 }
1633
1634 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1635 let instrument_id = request.single_instrument_id()?;
1636
1637 log::debug!("Requesting Lighter trades for {instrument_id}");
1638
1639 let instrument = self
1640 .instruments
1641 .get_cloned(&instrument_id)
1642 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1643
1644 let http = self.http_client.clone();
1645 let sender = self.data_sender.clone();
1646 let request_id = request.request_id;
1647 let client_id = request.client_id.unwrap_or(self.client_id);
1648 let limit = clamp_recent_trades_limit(request.limit);
1649 let start_nanos = datetime_to_unix_nanos(request.start);
1650 let end_nanos = datetime_to_unix_nanos(request.end);
1651 let params = request.params;
1652 let clock = self.clock;
1653
1654 self.spawn_task(async move {
1655 match http.request_recent_trades(&instrument, limit).await {
1656 Ok(mut trades) => {
1657 retain_trade_ticks_in_range(&mut trades, start_nanos, end_nanos);
1658
1659 let response = DataResponse::Trades(TradesResponse::new(
1660 request_id,
1661 client_id,
1662 instrument_id,
1663 trades,
1664 start_nanos,
1665 end_nanos,
1666 clock.get_time_ns(),
1667 params,
1668 ));
1669
1670 if let Err(e) = sender.send(DataEvent::Response(response)) {
1671 log::error!("Failed to send trades response: {e}");
1672 }
1673 }
1674 Err(e) => {
1675 log::error!("Lighter trades request failed for {instrument_id}: {e}");
1676 }
1677 }
1678 });
1679
1680 Ok(())
1681 }
1682
1683 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1684 let instrument_id = request.single_instrument_id()?;
1685
1686 log::debug!("Requesting Lighter funding rates for {instrument_id}");
1687
1688 let instrument = self
1689 .instruments
1690 .get_cloned(&instrument_id)
1691 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1692
1693 anyhow::ensure!(
1694 matches!(instrument, InstrumentAny::CryptoPerpetual(_)),
1695 "Lighter funding-rate requests require a perpetual instrument: {instrument_id}",
1696 );
1697
1698 let http = self.http_client.clone();
1699 let sender = self.data_sender.clone();
1700 let request_id = request.request_id;
1701 let client_id = request.client_id.unwrap_or(self.client_id);
1702 let start = request.start;
1703 let end = request.end;
1704 let limit = request.limit.map(|n| n.get());
1705 let start_nanos = datetime_to_unix_nanos(start);
1706 let end_nanos = datetime_to_unix_nanos(end);
1707 let params = request.params;
1708 let clock = self.clock;
1709
1710 self.spawn_task(async move {
1711 match http
1712 .request_funding_rates(&instrument, start, end, limit)
1713 .await
1714 {
1715 Ok(funding_rates) => {
1716 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1717 request_id,
1718 client_id,
1719 instrument_id,
1720 funding_rates,
1721 start_nanos,
1722 end_nanos,
1723 clock.get_time_ns(),
1724 params,
1725 ));
1726
1727 if let Err(e) = sender.send(DataEvent::Response(response)) {
1728 log::error!("Failed to send funding rates response: {e}");
1729 }
1730 }
1731 Err(e) => {
1732 log::error!("Lighter funding rates request failed for {instrument_id}: {e:?}");
1733 }
1734 }
1735 });
1736
1737 Ok(())
1738 }
1739
1740 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1741 let instrument_id = request.single_instrument_id()?;
1742
1743 log::debug!("Requesting Lighter book snapshot for {instrument_id}");
1744
1745 let instrument = self
1746 .instruments
1747 .get_cloned(&instrument_id)
1748 .ok_or_else(|| InstrumentLookupError::not_found(instrument_id))?;
1749
1750 let market_index = self.registry.market_index(&instrument_id).ok_or_else(|| {
1751 anyhow::anyhow!("No Lighter market_index registered for {instrument_id}")
1752 })?;
1753
1754 let http = self.http_client.clone();
1755 let sender = self.data_sender.clone();
1756 let request_id = request.request_id;
1757 let client_id = request.client_id.unwrap_or(self.client_id);
1758 let limit = clamp_book_snapshot_limit(request.depth);
1759 let params = request.params;
1760 let clock = self.clock;
1761 let price_precision = instrument.price_precision();
1762 let size_precision = instrument.size_precision();
1763
1764 let query = LighterOrderBookOrdersQuery {
1765 market_id: market_index,
1766 limit,
1767 };
1768
1769 self.spawn_task(async move {
1770 match http.inner.get_order_book_orders(&query).await {
1771 Ok(snapshot) => {
1772 let ts_init = clock.get_time_ns();
1773 let book = parse_l2_order_book_snapshot(
1774 &snapshot,
1775 instrument_id,
1776 price_precision,
1777 size_precision,
1778 );
1779
1780 let response = DataResponse::Book(BookResponse::new(
1781 request_id,
1782 client_id,
1783 instrument_id,
1784 book,
1785 None,
1786 None,
1787 ts_init,
1788 params,
1789 ));
1790
1791 if let Err(e) = sender.send(DataEvent::Response(response)) {
1792 log::error!("Failed to send book snapshot response: {e}");
1793 }
1794 }
1795 Err(e) => {
1796 log::error!("Lighter book snapshot request failed for {instrument_id}: {e:?}");
1797 }
1798 }
1799 });
1800
1801 Ok(())
1802 }
1803
1804 fn request_book_depth(&self, request: RequestBookDepth) -> anyhow::Result<()> {
1805 let instrument_id = request.single_instrument_id()?;
1806
1807 anyhow::bail!(
1808 "Lighter does not support historical order book depth requests for {instrument_id}; \
1809 use request_book_snapshot for an L2 snapshot or subscribe_book_depth for live depth"
1810 )
1811 }
1812}
1813
1814fn retain_trade_ticks_in_range(
1815 trades: &mut Vec<TradeTick>,
1816 start_nanos: Option<UnixNanos>,
1817 end_nanos: Option<UnixNanos>,
1818) {
1819 trades.retain(|trade| trade_tick_in_range(trade.ts_event, start_nanos, end_nanos));
1820 trades.sort_by_key(|trade| trade.ts_event);
1821}
1822
1823fn trade_tick_in_range(
1824 ts_event: UnixNanos,
1825 start_nanos: Option<UnixNanos>,
1826 end_nanos: Option<UnixNanos>,
1827) -> bool {
1828 start_nanos.is_none_or(|start| ts_event >= start) && end_nanos.is_none_or(|end| ts_event <= end)
1829}
1830
1831fn validate_book_deltas_subscription(book_type: BookType) -> anyhow::Result<()> {
1836 validate_l2_mbp_book_type(book_type, "deltas")
1837}
1838
1839fn validate_book_depth_subscription(book_type: BookType) -> anyhow::Result<()> {
1840 validate_l2_mbp_book_type(book_type, "depth")
1841}
1842
1843fn validate_l2_mbp_book_type(book_type: BookType, label: &str) -> anyhow::Result<()> {
1844 anyhow::ensure!(
1845 book_type == BookType::L2_MBP,
1846 "Lighter only supports L2_MBP order book {label}",
1847 );
1848 Ok(())
1849}
1850
1851#[cfg(test)]
1852mod tests {
1853 use std::{num::NonZeroUsize, time::Duration};
1854
1855 use axum::{
1856 Router,
1857 extract::Query,
1858 http::StatusCode,
1859 response::{IntoResponse, Response},
1860 routing::get,
1861 };
1862 use jiff::Timestamp;
1863 use nautilus_common::live::runner::replace_data_event_sender;
1864 use nautilus_core::UUID4;
1865 use nautilus_model::{
1866 data::{
1867 BarSpecification, BarType, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
1868 TradeTick,
1869 },
1870 enums::{AggregationSource, AggressorSide, BarAggregation, PriceType},
1871 identifiers::{InstrumentId, Symbol, TradeId},
1872 instruments::{CryptoPerpetual, CurrencyPair},
1873 types::{Currency, Price, Quantity},
1874 };
1875 use rstest::rstest;
1876 use rust_decimal::Decimal;
1877
1878 use super::{
1879 limits::{LIGHTER_BOOK_ORDERS_MAX_LIMIT, LIGHTER_RECENT_TRADES_MAX_LIMIT},
1880 market_stats::{MarketStatsFlags, MarketStatsSubscription},
1881 *,
1882 };
1883 use crate::{
1884 common::{
1885 consts::LIGHTER_VENUE,
1886 enums::{LighterFundingResolution, LighterProductType},
1887 },
1888 http::query::{LighterFundingsQuery, LighterRecentTradesQuery},
1889 };
1890
1891 struct DropSignal(Option<tokio::sync::oneshot::Sender<()>>);
1892
1893 impl Drop for DropSignal {
1894 fn drop(&mut self) {
1895 if let Some(sender) = self.0.take() {
1896 let _ = sender.send(());
1897 }
1898 }
1899 }
1900
1901 const HTTP_ORDER_BOOK_DETAILS: &str =
1902 include_str!("../../test_data/http_order_book_details.json");
1903 const HTTP_FUNDINGS: &str = include_str!("../../test_data/http_fundings.json");
1904 const HTTP_RECENT_TRADES: &str = include_str!("../../test_data/http_recent_trades.json");
1905 const HTTP_RECENT_TRADES_NULL: &str =
1906 include_str!("../../test_data/http_recent_trades_null.json");
1907 const HTTP_RECENT_TRADES_UNORDERED: &str =
1908 include_str!("../../test_data/http_recent_trades_unordered.json");
1909 const PRIVATE_KEY_HEX: &str =
1910 "0b8e0f63c24d8baacd9d29ad4e9a4b73c4a8d2bb8b16dc4fa9d7c2e1d3a8b1f0e8d3a4c5b6e7f001";
1911
1912 #[rstest]
1913 #[case::none_defaults_to_cap(None, LIGHTER_BOOK_ORDERS_MAX_LIMIT)]
1914 #[case::below_cap_passes_through(Some(10), 10)]
1915 #[case::at_cap_passes_through(
1916 Some(LIGHTER_BOOK_ORDERS_MAX_LIMIT as usize),
1917 LIGHTER_BOOK_ORDERS_MAX_LIMIT
1918 )]
1919 #[case::above_cap_clamps(Some(500), LIGHTER_BOOK_ORDERS_MAX_LIMIT)]
1920 #[case::usize_max_clamps(Some(usize::MAX), LIGHTER_BOOK_ORDERS_MAX_LIMIT)]
1921 fn test_clamp_book_snapshot_limit(#[case] depth: Option<usize>, #[case] expected: u16) {
1922 let depth = depth.map(|n| NonZeroUsize::new(n).expect("non-zero"));
1923 assert_eq!(clamp_book_snapshot_limit(depth), expected);
1924 }
1925
1926 #[rstest]
1927 #[case::none_defaults_to_cap(None, LIGHTER_RECENT_TRADES_MAX_LIMIT)]
1928 #[case::below_cap_passes_through(Some(10), 10)]
1929 #[case::at_cap_passes_through(
1930 Some(LIGHTER_RECENT_TRADES_MAX_LIMIT as usize),
1931 LIGHTER_RECENT_TRADES_MAX_LIMIT
1932 )]
1933 #[case::above_cap_clamps(Some(500), LIGHTER_RECENT_TRADES_MAX_LIMIT)]
1934 #[case::usize_max_clamps(Some(usize::MAX), LIGHTER_RECENT_TRADES_MAX_LIMIT)]
1935 fn test_clamp_recent_trades_limit(#[case] limit: Option<usize>, #[case] expected: u16) {
1936 let limit = limit.map(|n| NonZeroUsize::new(n).expect("non-zero"));
1937 assert_eq!(clamp_recent_trades_limit(limit), expected);
1938 }
1939
1940 #[rstest]
1941 fn test_new_uses_readonly_websocket_url() {
1942 let client = create_data_client_for_test();
1943
1944 assert_eq!(
1945 client.ws_client.url(),
1946 "wss://mainnet.zklighter.elliot.ai/stream?readonly=true",
1947 );
1948 }
1949
1950 #[rstest]
1951 fn test_validate_book_deltas_accepts_l2_mbp() {
1952 assert!(validate_book_deltas_subscription(BookType::L2_MBP).is_ok());
1953 }
1954
1955 #[rstest]
1956 #[case(BookType::L1_MBP)]
1957 #[case(BookType::L3_MBO)]
1958 fn test_validate_book_deltas_rejects_other_book_types(#[case] book_type: BookType) {
1959 let err = validate_book_deltas_subscription(book_type).unwrap_err();
1960 assert!(
1961 err.to_string().contains("L2_MBP"),
1962 "expected error to cite L2_MBP, was: {err}",
1963 );
1964 }
1965
1966 #[rstest]
1967 fn test_validate_book_depth_accepts_l2_mbp() {
1968 assert!(validate_book_depth_subscription(BookType::L2_MBP).is_ok());
1969 }
1970
1971 #[rstest]
1972 #[case(BookType::L1_MBP)]
1973 #[case(BookType::L3_MBO)]
1974 fn test_validate_book_depth_rejects_other_book_types(#[case] book_type: BookType) {
1975 let err = validate_book_depth_subscription(book_type).unwrap_err();
1976 assert!(
1977 err.to_string().contains("depth"),
1978 "expected error to cite depth, was: {err}",
1979 );
1980 }
1981
1982 #[rstest]
1983 #[case(LighterMarketStatus::Active, MarketStatusAction::Trading)]
1984 #[case(
1985 LighterMarketStatus::Inactive,
1986 MarketStatusAction::NotAvailableForTrading
1987 )]
1988 fn test_lighter_market_status_action(
1989 #[case] status: LighterMarketStatus,
1990 #[case] expected: MarketStatusAction,
1991 ) {
1992 assert_eq!(lighter_market_status_action(status), expected);
1993 }
1994
1995 #[tokio::test]
1996 async fn test_subscribe_instrument_status_replays_cached_status() {
1997 let (mut client, mut receiver) = create_data_client_with_receiver_for_test();
1998 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
1999 cache_lighter_instrument_status(
2000 &client.instrument_statuses,
2001 instrument_id,
2002 LighterMarketStatus::Active,
2003 );
2004
2005 DataClient::subscribe_instrument_status(
2006 &mut client,
2007 SubscribeInstrumentStatus::new(
2008 instrument_id,
2009 Some(ClientId::new("LIGHTER")),
2010 None,
2011 UUID4::new(),
2012 UnixNanos::default(),
2013 None,
2014 None,
2015 ),
2016 )
2017 .unwrap();
2018
2019 let event = receiver.recv().await.expect("instrument status event");
2020 match event {
2021 DataEvent::Data(Data::InstrumentStatus(status)) => {
2022 assert_eq!(status.instrument_id, instrument_id);
2023 assert_eq!(status.action, MarketStatusAction::Trading);
2024 assert_eq!(status.is_trading, Some(true));
2025 }
2026 event => panic!("expected instrument status, was {event:?}"),
2027 }
2028 }
2029
2030 #[tokio::test]
2031 async fn test_subscribe_instrument_status_fetches_when_cache_is_empty() {
2032 let base_url = spawn_order_book_details_server().await;
2033 let config = LighterDataClientConfig {
2034 base_url_http: Some(base_url),
2035 ..Default::default()
2036 };
2037 let (mut client, mut receiver) =
2038 create_data_client_with_receiver_and_config_for_test(config);
2039 let instrument_id = client.registry.insert(0, "ETH", LighterProductType::Perp);
2040
2041 DataClient::subscribe_instrument_status(
2042 &mut client,
2043 SubscribeInstrumentStatus::new(
2044 instrument_id,
2045 Some(ClientId::new("LIGHTER")),
2046 None,
2047 UUID4::new(),
2048 UnixNanos::default(),
2049 None,
2050 None,
2051 ),
2052 )
2053 .unwrap();
2054
2055 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2056 .await
2057 .expect("instrument status response")
2058 .expect("instrument status event");
2059
2060 match event {
2061 DataEvent::Data(Data::InstrumentStatus(status)) => {
2062 assert_eq!(status.instrument_id, instrument_id);
2063 assert_eq!(status.action, MarketStatusAction::Trading);
2064 assert_eq!(status.is_trading, Some(true));
2065 }
2066 event => panic!("expected instrument status, was {event:?}"),
2067 }
2068 assert!(client.instruments.get_cloned(&instrument_id).is_some());
2069 assert_eq!(
2070 client
2071 .instrument_statuses
2072 .get(&instrument_id)
2073 .map(|status| *status),
2074 Some(LighterMarketStatus::Active),
2075 );
2076 }
2077
2078 #[tokio::test]
2079 async fn test_market_stats_subscriptions_share_perp_channel_until_last_unsub() {
2080 let mut client = create_data_client_for_test();
2081 client.cancellation_token.cancel();
2083 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2084
2085 DataClient::subscribe_mark_prices(
2086 &mut client,
2087 SubscribeMarkPrices::new(
2088 instrument_id,
2089 Some(ClientId::new("LIGHTER")),
2090 None,
2091 UUID4::new(),
2092 UnixNanos::default(),
2093 None,
2094 None,
2095 ),
2096 )
2097 .unwrap();
2098 DataClient::subscribe_index_prices(
2099 &mut client,
2100 SubscribeIndexPrices::new(
2101 instrument_id,
2102 Some(ClientId::new("LIGHTER")),
2103 None,
2104 UUID4::new(),
2105 UnixNanos::default(),
2106 None,
2107 None,
2108 ),
2109 )
2110 .unwrap();
2111 DataClient::subscribe_funding_rates(
2112 &mut client,
2113 SubscribeFundingRates::new(
2114 instrument_id,
2115 Some(ClientId::new("LIGHTER")),
2116 None,
2117 UUID4::new(),
2118 UnixNanos::default(),
2119 None,
2120 None,
2121 ),
2122 )
2123 .unwrap();
2124
2125 let subscription = client
2126 .market_stats_subscriptions
2127 .get(&instrument_id)
2128 .expect("market stats subscription");
2129 assert_eq!(
2130 subscription.flags,
2131 MarketStatsFlags {
2132 mark_price: true,
2133 index_price: true,
2134 funding_rate: true,
2135 },
2136 );
2137 assert!(matches!(
2138 subscription.channel,
2139 LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
2140 ));
2141 drop(subscription);
2142 assert!(
2143 client
2144 .market_stats_subscription_generations
2145 .contains_key(&instrument_id),
2146 );
2147
2148 DataClient::unsubscribe_mark_prices(
2149 &mut client,
2150 &UnsubscribeMarkPrices::new(
2151 instrument_id,
2152 Some(ClientId::new("LIGHTER")),
2153 None,
2154 UUID4::new(),
2155 UnixNanos::default(),
2156 None,
2157 None,
2158 ),
2159 )
2160 .unwrap();
2161 assert_eq!(
2162 client
2163 .market_stats_subscriptions
2164 .get(&instrument_id)
2165 .expect("index and funding still active")
2166 .flags,
2167 MarketStatsFlags {
2168 index_price: true,
2169 funding_rate: true,
2170 ..Default::default()
2171 },
2172 );
2173
2174 DataClient::unsubscribe_index_prices(
2175 &mut client,
2176 &UnsubscribeIndexPrices::new(
2177 instrument_id,
2178 Some(ClientId::new("LIGHTER")),
2179 None,
2180 UUID4::new(),
2181 UnixNanos::default(),
2182 None,
2183 None,
2184 ),
2185 )
2186 .unwrap();
2187 assert_eq!(
2188 client
2189 .market_stats_subscriptions
2190 .get(&instrument_id)
2191 .expect("funding still active")
2192 .flags,
2193 MarketStatsFlags {
2194 funding_rate: true,
2195 ..Default::default()
2196 },
2197 );
2198
2199 DataClient::unsubscribe_funding_rates(
2200 &mut client,
2201 &UnsubscribeFundingRates::new(
2202 instrument_id,
2203 Some(ClientId::new("LIGHTER")),
2204 None,
2205 UUID4::new(),
2206 UnixNanos::default(),
2207 None,
2208 None,
2209 ),
2210 )
2211 .unwrap();
2212 assert!(
2213 !client
2214 .market_stats_subscriptions
2215 .contains_key(&instrument_id)
2216 );
2217 assert!(
2218 !client
2219 .market_stats_subscription_generations
2220 .contains_key(&instrument_id),
2221 );
2222 }
2223
2224 #[rstest]
2225 fn test_market_stats_ws_forwarding_requires_matching_subscription() {
2226 let subscriptions = DashMap::new();
2227 let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
2228 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2229 let other_instrument_id = InstrumentId::new(Symbol::new("BTC-PERP"), *LIGHTER_VENUE);
2230
2231 subscriptions.insert(
2232 instrument_id,
2233 MarketStatsSubscription {
2234 channel: LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
2235 flags: MarketStatsFlags {
2236 mark_price: true,
2237 index_price: true,
2238 funding_rate: true,
2239 },
2240 },
2241 );
2242
2243 assert!(emit_market_stats_ws_message(
2244 &sender.clone().into(),
2245 &subscriptions,
2246 &NautilusWsMessage::MarkPrice(MarkPriceUpdate::new(
2247 instrument_id,
2248 Price::from("2000.00"),
2249 UnixNanos::from(10),
2250 UnixNanos::from(1),
2251 )),
2252 ));
2253 assert!(emit_market_stats_ws_message(
2254 &sender.clone().into(),
2255 &subscriptions,
2256 &NautilusWsMessage::IndexPrice(IndexPriceUpdate::new(
2257 instrument_id,
2258 Price::from("1999.50"),
2259 UnixNanos::from(11),
2260 UnixNanos::from(1),
2261 )),
2262 ));
2263 assert!(emit_market_stats_ws_message(
2264 &sender.clone().into(),
2265 &subscriptions,
2266 &NautilusWsMessage::FundingRate(FundingRateUpdate::new(
2267 instrument_id,
2268 Decimal::new(12, 6),
2269 None,
2270 Some(UnixNanos::from(100)),
2271 UnixNanos::from(12),
2272 UnixNanos::from(1),
2273 )),
2274 ));
2275
2276 match receiver.try_recv().unwrap() {
2277 DataEvent::Data(Data::MarkPrice(update)) => {
2278 assert_eq!(update.instrument_id, instrument_id);
2279 assert_eq!(update.value, Price::from("2000.00"));
2280 }
2281 event => panic!("expected mark price update, was {event:?}"),
2282 }
2283
2284 match receiver.try_recv().unwrap() {
2285 DataEvent::Data(Data::IndexPrice(update)) => {
2286 assert_eq!(update.instrument_id, instrument_id);
2287 assert_eq!(update.value, Price::from("1999.50"));
2288 }
2289 event => panic!("expected index price update, was {event:?}"),
2290 }
2291
2292 match receiver.try_recv().unwrap() {
2293 DataEvent::Data(Data::FundingRate(update)) => {
2294 assert_eq!(update.instrument_id, instrument_id);
2295 assert_eq!(update.rate, Decimal::new(12, 6));
2296 }
2297 event => panic!("expected funding rate update, was {event:?}"),
2298 }
2299
2300 assert!(!emit_market_stats_ws_message(
2301 &sender.into(),
2302 &subscriptions,
2303 &NautilusWsMessage::MarkPrice(MarkPriceUpdate::new(
2304 other_instrument_id,
2305 Price::from("1.00"),
2306 UnixNanos::from(13),
2307 UnixNanos::from(1),
2308 )),
2309 ));
2310 assert!(receiver.try_recv().is_err());
2311 }
2312
2313 #[rstest]
2314 fn test_index_market_stats_channel_uses_spot_stream_for_spot_instrument() {
2315 let client = create_data_client_for_test();
2316 let instrument_id = cache_test_instrument(&client, 2048, "ETH", LighterProductType::Spot);
2317
2318 let channel = client.index_market_stats_channel(instrument_id).unwrap();
2319
2320 assert!(matches!(
2321 channel,
2322 LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(2048)),
2323 ));
2324 }
2325
2326 #[rstest]
2327 fn test_index_market_stats_channel_routes_widened_ids_by_instrument_type() {
2328 let client = create_data_client_for_test();
2329 let perp_id = cache_test_instrument(&client, 40_000, "ETH", LighterProductType::Perp);
2330 let spot_id = cache_test_instrument(&client, 50_000, "ETH", LighterProductType::Spot);
2331
2332 let perp_channel = client.index_market_stats_channel(perp_id).unwrap();
2333 let spot_channel = client.index_market_stats_channel(spot_id).unwrap();
2334
2335 assert!(matches!(
2336 perp_channel,
2337 LighterWsChannel::MarketStats(LighterMarketSelection::Market(40_000)),
2338 ));
2339 assert!(matches!(
2340 spot_channel,
2341 LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(50_000)),
2342 ));
2343 }
2344
2345 #[rstest]
2346 fn test_mark_price_channel_rejects_spot_instrument() {
2347 let client = create_data_client_for_test();
2348 let instrument_id = cache_test_instrument(&client, 2048, "ETH", LighterProductType::Spot);
2349
2350 let err = client
2351 .perp_market_stats_channel(instrument_id, "mark price")
2352 .unwrap_err();
2353
2354 assert!(
2355 err.to_string()
2356 .contains("mark price subscriptions require a perpetual instrument"),
2357 );
2358 }
2359
2360 #[rstest]
2361 fn test_request_bars_rejects_unsupported_bar_type() {
2362 let client = create_data_client_for_test();
2363 let request = RequestBars::new(
2364 unsupported_three_minute_bar_type(),
2365 None,
2366 None,
2367 None,
2368 Some(ClientId::new("LIGHTER")),
2369 UUID4::new(),
2370 UnixNanos::default(),
2371 None,
2372 );
2373
2374 let err = DataClient::request_bars(&client, request).unwrap_err();
2375
2376 assert_eq!(err.to_string(), "unsupported Lighter candle minute step: 3");
2377 }
2378
2379 #[rstest]
2380 fn test_subscribe_bars_rejects_unsupported_bar_type() {
2381 let mut client = create_data_client_for_test();
2382 let subscription = SubscribeBars::new(
2383 unsupported_three_minute_bar_type(),
2384 Some(ClientId::new("LIGHTER")),
2385 None,
2386 UUID4::new(),
2387 UnixNanos::default(),
2388 None,
2389 None,
2390 );
2391
2392 let err = DataClient::subscribe_bars(&mut client, subscription).unwrap_err();
2393
2394 assert_eq!(err.to_string(), "unsupported Lighter candle minute step: 3");
2395 }
2396
2397 #[rstest]
2398 fn test_subscribe_bars_accepts_ws_streamable_resolution() {
2399 let mut client = create_data_client_for_test();
2400 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2401 let bar_type = BarType::new(
2402 instrument_id,
2403 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2404 AggregationSource::External,
2405 );
2406 let subscription = SubscribeBars::new(
2407 bar_type,
2408 Some(ClientId::new("LIGHTER")),
2409 None,
2410 UUID4::new(),
2411 UnixNanos::default(),
2412 None,
2413 None,
2414 );
2415
2416 DataClient::subscribe_bars(&mut client, subscription).unwrap();
2417 }
2418
2419 #[rstest]
2420 fn test_subscribe_bars_missing_cached_instrument_returns_lookup_error() {
2421 let mut client = create_data_client_for_test();
2422 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2423 let bar_type = BarType::new(
2424 instrument_id,
2425 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
2426 AggregationSource::External,
2427 );
2428 let subscription = SubscribeBars::new(
2429 bar_type,
2430 Some(ClientId::new("LIGHTER")),
2431 None,
2432 UUID4::new(),
2433 UnixNanos::default(),
2434 None,
2435 None,
2436 );
2437
2438 let err = DataClient::subscribe_bars(&mut client, subscription).unwrap_err();
2439
2440 assert_eq!(
2441 err.to_string(),
2442 InstrumentLookupError::not_found(instrument_id).to_string()
2443 );
2444 }
2445
2446 #[rstest]
2447 fn test_subscribe_bars_rejects_one_week_with_ws_message() {
2448 let mut client = create_data_client_for_test();
2449 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2450 let bar_type = BarType::new(
2451 instrument_id,
2452 BarSpecification::new(1, BarAggregation::Week, PriceType::Last),
2453 AggregationSource::External,
2454 );
2455 let subscription = SubscribeBars::new(
2456 bar_type,
2457 Some(ClientId::new("LIGHTER")),
2458 None,
2459 UUID4::new(),
2460 UnixNanos::default(),
2461 None,
2462 None,
2463 );
2464
2465 let err = DataClient::subscribe_bars(&mut client, subscription).unwrap_err();
2466
2467 assert!(
2468 err.to_string().contains("does not offer")
2469 && err.to_string().contains("candle WebSocket stream"),
2470 "expected WS-streamable rejection, was: {err}",
2471 );
2472 }
2473
2474 #[rstest]
2475 fn test_unsubscribe_bars_returns_ok_for_unsupported_bar_type() {
2476 let mut client = create_data_client_for_test();
2477 let unsubscription = UnsubscribeBars::new(
2478 unsupported_three_minute_bar_type(),
2479 Some(ClientId::new("LIGHTER")),
2480 None,
2481 UUID4::new(),
2482 UnixNanos::default(),
2483 None,
2484 None,
2485 );
2486
2487 DataClient::unsubscribe_bars(&mut client, &unsubscription).unwrap();
2488 }
2489
2490 #[rstest]
2491 fn test_subscribe_book_depth_rejects_unsupported_book_type() {
2492 let mut client = create_data_client_for_test();
2493 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2494 let subscription = SubscribeBookDepth::new(
2495 instrument_id,
2496 BookType::L1_MBP,
2497 Some(ClientId::new("LIGHTER")),
2498 None,
2499 UUID4::new(),
2500 UnixNanos::default(),
2501 None,
2502 false,
2503 None,
2504 None,
2505 );
2506
2507 let err = DataClient::subscribe_book_depth(&mut client, subscription).unwrap_err();
2508
2509 assert!(err.to_string().contains("L2_MBP"));
2510 }
2511
2512 #[rstest]
2513 fn test_request_quotes_rejects_unsupported_rest_quotes() {
2514 let client = create_data_client_for_test();
2515 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2516 let request = RequestQuotes::new(
2517 instrument_id,
2518 None,
2519 None,
2520 None,
2521 Some(ClientId::new("LIGHTER")),
2522 UUID4::new(),
2523 UnixNanos::default(),
2524 None,
2525 );
2526
2527 let err = DataClient::request_quotes(&client, request).unwrap_err();
2528
2529 assert!(
2530 err.to_string()
2531 .contains("does not support historical quote requests"),
2532 );
2533 }
2534
2535 #[rstest]
2536 fn test_request_book_depth_rejects_unsupported_rest_depth() {
2537 let client = create_data_client_for_test();
2538 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2539 let request = RequestBookDepth::new(
2540 instrument_id,
2541 None,
2542 None,
2543 None,
2544 NonZeroUsize::new(10),
2545 Some(ClientId::new("LIGHTER")),
2546 UUID4::new(),
2547 UnixNanos::default(),
2548 None,
2549 );
2550
2551 let err = DataClient::request_book_depth(&client, request).unwrap_err();
2552
2553 assert!(
2554 err.to_string()
2555 .contains("does not support historical order book depth requests"),
2556 );
2557 }
2558
2559 #[rstest]
2560 fn test_request_funding_rates_rejects_spot_instrument() {
2561 let client = create_data_client_for_test();
2562 let instrument_id = cache_test_instrument(&client, 2048, "ETH", LighterProductType::Spot);
2563 let request = RequestFundingRates::new(
2564 instrument_id,
2565 None,
2566 None,
2567 None,
2568 Some(ClientId::new("LIGHTER")),
2569 UUID4::new(),
2570 UnixNanos::default(),
2571 None,
2572 );
2573
2574 let err = DataClient::request_funding_rates(&client, request).unwrap_err();
2575
2576 assert!(
2577 err.to_string()
2578 .contains("funding-rate requests require a perpetual instrument"),
2579 );
2580 }
2581
2582 #[tokio::test]
2583 async fn test_request_funding_rates_emits_response() {
2584 let base_url = spawn_fundings_server().await;
2585 let config = LighterDataClientConfig {
2586 base_url_http: Some(base_url),
2587 ..Default::default()
2588 };
2589 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2590 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2591 let start = Timestamp::from_second(1_778_702_400).unwrap();
2592 let end = Timestamp::from_second(1_778_706_000).unwrap();
2593 let request = RequestFundingRates::new(
2594 instrument_id,
2595 Some(start),
2596 Some(end),
2597 NonZeroUsize::new(2),
2598 Some(ClientId::new("LIGHTER")),
2599 UUID4::new(),
2600 UnixNanos::default(),
2601 None,
2602 );
2603
2604 DataClient::request_funding_rates(&client, request).unwrap();
2605
2606 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2607 .await
2608 .expect("funding rates response")
2609 .expect("funding rates event");
2610
2611 match event {
2612 DataEvent::Response(DataResponse::FundingRates(response)) => {
2613 assert_eq!(response.instrument_id, instrument_id);
2614 assert_eq!(response.data.len(), 2);
2615 assert_eq!(response.data[0].rate, Decimal::new(12, 6));
2616 assert_eq!(response.data[0].interval, Some(60));
2617 assert_eq!(
2618 response.data[0].ts_event,
2619 UnixNanos::from(1_778_702_400_000_000_000)
2620 );
2621 assert_eq!(response.data[1].rate, Decimal::new(-2, 6));
2622 assert_eq!(response.data[1].interval, Some(60));
2623 }
2624 event => panic!("expected funding rates response, was {event:?}"),
2625 }
2626 }
2627
2628 #[tokio::test]
2629 async fn test_request_trades_uses_recent_trades_endpoint() {
2630 let base_url = spawn_trades_server().await;
2631 let config = LighterDataClientConfig {
2632 base_url_http: Some(base_url),
2633 ..Default::default()
2634 };
2635 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2636 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2637 let start = Timestamp::from_second(1_700_000_000).unwrap();
2638 let request = RequestTrades::new(
2639 instrument_id,
2640 Some(start),
2641 None,
2642 NonZeroUsize::new(50),
2643 Some(ClientId::new("LIGHTER")),
2644 UUID4::new(),
2645 UnixNanos::default(),
2646 None,
2647 );
2648
2649 DataClient::request_trades(&client, request).unwrap();
2650
2651 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2652 .await
2653 .expect("trades response")
2654 .expect("trades event");
2655
2656 match event {
2657 DataEvent::Response(DataResponse::Trades(response)) => {
2658 assert_eq!(response.instrument_ids, vec![instrument_id]);
2659 assert_eq!(response.data.len(), 1);
2660 let tick = &response.data[0];
2661 assert_eq!(tick.instrument_id, instrument_id);
2662 assert_eq!(tick.price, Price::from("2361.31"));
2663 assert_eq!(tick.size, Quantity::from("0.0005"));
2664 assert_eq!(tick.aggressor_side, AggressorSide::Sell);
2665 assert_eq!(tick.trade_id.to_string(), "19211490282");
2666 }
2667 event => panic!("expected trades response, was {event:?}"),
2668 }
2669 }
2670
2671 #[tokio::test]
2672 async fn test_request_trades_clamps_limit_to_venue_cap() {
2673 let base_url = spawn_trades_server_with_response_and_limit(
2674 HTTP_RECENT_TRADES,
2675 LIGHTER_RECENT_TRADES_MAX_LIMIT,
2676 )
2677 .await;
2678 let config = LighterDataClientConfig {
2679 base_url_http: Some(base_url),
2680 ..Default::default()
2681 };
2682 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2683 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2684 let request = RequestTrades::new(
2685 instrument_id,
2686 None,
2687 None,
2688 NonZeroUsize::new(usize::from(LIGHTER_RECENT_TRADES_MAX_LIMIT) + 1),
2689 Some(ClientId::new("LIGHTER")),
2690 UUID4::new(),
2691 UnixNanos::default(),
2692 None,
2693 );
2694
2695 DataClient::request_trades(&client, request).unwrap();
2696
2697 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2698 .await
2699 .expect("trades response")
2700 .expect("trades event");
2701
2702 assert!(
2703 matches!(event, DataEvent::Response(DataResponse::Trades(_))),
2704 "expected trades response, was {event:?}",
2705 );
2706 }
2707
2708 #[tokio::test]
2709 async fn test_request_trades_emits_empty_response_for_null_recent_trades() {
2710 let base_url = spawn_trades_server_with_response(HTTP_RECENT_TRADES_NULL).await;
2711 let config = LighterDataClientConfig {
2712 base_url_http: Some(base_url),
2713 ..Default::default()
2714 };
2715 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2716 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2717 let start = Timestamp::from_second(1_700_000_000).unwrap();
2718 let request = RequestTrades::new(
2719 instrument_id,
2720 Some(start),
2721 None,
2722 NonZeroUsize::new(50),
2723 Some(ClientId::new("LIGHTER")),
2724 UUID4::new(),
2725 UnixNanos::default(),
2726 None,
2727 );
2728
2729 DataClient::request_trades(&client, request).unwrap();
2730
2731 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2732 .await
2733 .expect("trades response")
2734 .expect("trades event");
2735
2736 match event {
2737 DataEvent::Response(DataResponse::Trades(response)) => {
2738 assert_eq!(response.instrument_ids, vec![instrument_id]);
2739 assert!(response.data.is_empty());
2740 }
2741 event => panic!("expected trades response, was {event:?}"),
2742 }
2743 }
2744
2745 #[tokio::test]
2746 async fn test_request_trades_filters_recent_trades_to_requested_range() {
2747 let base_url = spawn_trades_server().await;
2748 let config = LighterDataClientConfig {
2749 base_url_http: Some(base_url),
2750 ..Default::default()
2751 };
2752 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2753 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2754 let end = Timestamp::from_second(1_700_000_000).unwrap();
2755 let request = RequestTrades::new(
2756 instrument_id,
2757 None,
2758 Some(end),
2759 NonZeroUsize::new(50),
2760 Some(ClientId::new("LIGHTER")),
2761 UUID4::new(),
2762 UnixNanos::default(),
2763 None,
2764 );
2765
2766 DataClient::request_trades(&client, request).unwrap();
2767
2768 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2769 .await
2770 .expect("trades response")
2771 .expect("trades event");
2772
2773 match event {
2774 DataEvent::Response(DataResponse::Trades(response)) => {
2775 assert_eq!(response.instrument_ids, vec![instrument_id]);
2776 assert!(response.data.is_empty());
2777 }
2778 event => panic!("expected trades response, was {event:?}"),
2779 }
2780 }
2781
2782 #[tokio::test]
2783 async fn test_request_trades_returns_recent_trades_in_timestamp_order() {
2784 let base_url = spawn_trades_server_with_response(HTTP_RECENT_TRADES_UNORDERED).await;
2785 let config = LighterDataClientConfig {
2786 base_url_http: Some(base_url),
2787 ..Default::default()
2788 };
2789 let (client, mut receiver) = create_data_client_with_receiver_and_config_for_test(config);
2790 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2791 let start = Timestamp::from_millisecond(1_777_945_103_092).unwrap();
2792 let end = Timestamp::from_millisecond(1_777_945_103_094).unwrap();
2793 let request = RequestTrades::new(
2794 instrument_id,
2795 Some(start),
2796 Some(end),
2797 NonZeroUsize::new(50),
2798 Some(ClientId::new("LIGHTER")),
2799 UUID4::new(),
2800 UnixNanos::default(),
2801 None,
2802 );
2803
2804 DataClient::request_trades(&client, request).unwrap();
2805
2806 let event = tokio::time::timeout(Duration::from_secs(2), receiver.recv())
2807 .await
2808 .expect("trades response")
2809 .expect("trades event");
2810
2811 match event {
2812 DataEvent::Response(DataResponse::Trades(response)) => {
2813 assert_eq!(response.instrument_ids, vec![instrument_id]);
2814 assert_eq!(
2815 response
2816 .data
2817 .iter()
2818 .map(|trade| trade.trade_id.to_string())
2819 .collect::<Vec<_>>(),
2820 vec!["19211490282", "19211490283", "19211490284"],
2821 );
2822 assert_eq!(
2823 response
2824 .data
2825 .iter()
2826 .map(|trade| trade.ts_event.as_u64())
2827 .collect::<Vec<_>>(),
2828 vec![
2829 1_777_945_103_092_000_000,
2830 1_777_945_103_093_000_000,
2831 1_777_945_103_094_000_000,
2832 ],
2833 );
2834 }
2835 event => panic!("expected trades response, was {event:?}"),
2836 }
2837 }
2838
2839 #[rstest]
2840 fn test_retain_trade_ticks_in_range_sorts_ascending() {
2841 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
2842 let tick = |ts_event, trade_id| {
2843 TradeTick::new(
2844 instrument_id,
2845 Price::from("1.0"),
2846 Quantity::from("1.0"),
2847 AggressorSide::Buy,
2848 TradeId::new(trade_id),
2849 UnixNanos::from(ts_event),
2850 UnixNanos::from(ts_event + 1),
2851 )
2852 };
2853 let mut trades = vec![tick(4, "4"), tick(1, "1"), tick(3, "3"), tick(2, "2")];
2854
2855 retain_trade_ticks_in_range(
2856 &mut trades,
2857 Some(UnixNanos::from(2)),
2858 Some(UnixNanos::from(4)),
2859 );
2860
2861 assert_eq!(
2862 trades
2863 .iter()
2864 .map(|trade| trade.ts_event.as_u64())
2865 .collect::<Vec<_>>(),
2866 vec![2, 3, 4],
2867 );
2868 }
2869
2870 #[tokio::test]
2871 async fn test_spawn_instrument_refresh_skipped_when_interval_zero() {
2872 let config = LighterDataClientConfig {
2873 update_instruments_interval_mins: 0,
2874 ..Default::default()
2875 };
2876 let (client, _receiver) = create_data_client_with_receiver_and_config_for_test(config);
2877
2878 assert!(client.tasks.is_empty());
2879 client
2880 .spawn_instrument_refresh()
2881 .expect("instrument refresh remains disabled");
2882 assert!(client.tasks.is_empty());
2883 }
2884
2885 #[tokio::test]
2886 async fn test_spawn_instrument_refresh_registers_task() {
2887 let config = LighterDataClientConfig {
2888 update_instruments_interval_mins: 60,
2889 ..Default::default()
2890 };
2891 let (mut client, _receiver) = create_data_client_with_receiver_and_config_for_test(config);
2892
2893 assert!(client.tasks.is_empty());
2894 client
2895 .spawn_instrument_refresh()
2896 .expect("instrument refresh task registration");
2897 assert_eq!(client.tasks.len(), 1);
2898
2899 client.tasks.begin_shutdown();
2900 client.shutdown_tasks().await.expect("task shutdown");
2901 }
2902
2903 #[tokio::test]
2904 async fn test_await_instrument_refresh_drops_result_when_request_cancels() {
2905 let cancellation = CancellationToken::new();
2906 let request_cancellation = cancellation.clone();
2907
2908 let result = await_instrument_refresh(&cancellation, async move {
2909 request_cancellation.cancel();
2910 42
2911 })
2912 .await;
2913
2914 assert_eq!(result, None);
2915 }
2916
2917 #[tokio::test]
2918 async fn test_reset_closes_registered_task_generation_until_drain() {
2919 let (mut client, _receiver) = create_data_client_with_receiver_for_test();
2920 let old_token = client.cancellation_token.clone();
2921 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2922 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
2923
2924 client
2925 .tasks
2926 .spawn(async move {
2927 let _drop_signal = DropSignal(Some(dropped_tx));
2928 let _ = started_tx.send(());
2929 std::future::pending::<()>().await;
2930 })
2931 .expect("registered task spawn");
2932 started_rx.await.expect("registered task started");
2933
2934 client.reset().expect("reset");
2935
2936 assert!(old_token.is_cancelled());
2937 assert_eq!(client.tasks.len(), 1);
2938 assert!(!client.tasks.is_open());
2939 assert!(client.cancellation_token.is_cancelled());
2940 client.shutdown_tasks().await.expect("reset task shutdown");
2941 assert!(client.tasks.is_empty());
2942 tokio::time::timeout(Duration::from_secs(2), dropped_rx)
2943 .await
2944 .expect("registered task was not aborted")
2945 .expect("drop signal sender dropped");
2946 }
2947
2948 #[tokio::test]
2949 async fn test_spawn_task_suppresses_output_after_cancellation() {
2950 let (client, mut receiver) = create_data_client_with_receiver_for_test();
2951 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2952 let instrument = client
2953 .instruments
2954 .get_cloned(&instrument_id)
2955 .expect("cached instrument");
2956
2957 client.cancellation_token.cancel();
2959
2960 let sender = client.data_sender.clone();
2961 client.spawn_task(async move {
2962 let _ = sender.send(DataEvent::Data(instrument.into()));
2963 });
2964
2965 let result = tokio::time::timeout(Duration::from_millis(200), receiver.recv()).await;
2966 assert!(
2967 result.is_err(),
2968 "expected no DataEvent after cancellation, was {result:?}",
2969 );
2970 }
2971
2972 #[tokio::test]
2973 async fn test_connect_is_idempotent_when_already_connected() {
2974 let (mut client, _receiver) = create_data_client_with_receiver_for_test();
2975 client.is_connected.store(true, Ordering::Release);
2976
2977 client
2978 .connect()
2979 .await
2980 .expect("connect returns Ok when already connected");
2981
2982 assert!(
2983 client.tasks.is_empty(),
2984 "an already-connected client must not spawn duplicate tasks",
2985 );
2986 assert!(client.is_connected());
2987 }
2988
2989 #[tokio::test]
2990 async fn test_disconnect_drains_in_flight_task_and_suppresses_late_event() {
2991 let (mut client, mut receiver) = create_data_client_with_receiver_for_test();
2992 let instrument_id = cache_test_instrument(&client, 0, "ETH", LighterProductType::Perp);
2993 let instrument = client
2994 .instruments
2995 .get_cloned(&instrument_id)
2996 .expect("cached instrument");
2997 client.is_connected.store(true, Ordering::Release);
2998
2999 let sender = client.data_sender.clone();
3001 let (hold_tx, hold_rx) = tokio::sync::oneshot::channel::<()>();
3002 client.spawn_task(async move {
3003 let _ = hold_rx.await;
3004 let _ = sender.send(DataEvent::Data(instrument.into()));
3005 });
3006 assert_eq!(client.tasks.len(), 1);
3007
3008 client.disconnect().await.expect("disconnect");
3009
3010 assert!(
3011 client.tasks.is_empty(),
3012 "disconnect must drain tracked tasks",
3013 );
3014 assert!(!client.is_connected());
3015 let result = tokio::time::timeout(Duration::from_millis(200), receiver.recv()).await;
3016 assert!(
3017 result.is_err(),
3018 "expected no DataEvent after disconnect, was {result:?}",
3019 );
3020
3021 drop(hold_tx);
3022 }
3023
3024 #[tokio::test]
3025 async fn test_disconnect_aborts_task_that_ignores_cancellation() {
3026 let (mut client, _receiver) = create_data_client_with_receiver_for_test();
3027 client.is_connected.store(true, Ordering::Release);
3028
3029 let (started_tx, started_rx) = tokio::sync::oneshot::channel();
3030 let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel();
3031
3032 client
3034 .tasks
3035 .spawn(async move {
3036 let _drop_signal = DropSignal(Some(dropped_tx));
3037 let _ = started_tx.send(());
3038 std::future::pending::<()>().await;
3039 })
3040 .expect("uncancellable task spawn");
3041 started_rx.await.expect("task started");
3042
3043 client.disconnect().await.expect("disconnect");
3044
3045 assert!(client.tasks.is_empty());
3046 assert!(!client.is_connected());
3047 tokio::time::timeout(Duration::from_secs(5), dropped_rx)
3048 .await
3049 .expect("task aborted after timeout")
3050 .expect("drop signal sender dropped");
3051 }
3052
3053 #[rstest]
3054 fn test_rollback_market_stats_subscription_clears_piggybacked_flags() {
3055 let subscriptions = DashMap::new();
3056 let generations = DashMap::new();
3057 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
3058 subscriptions.insert(
3059 instrument_id,
3060 MarketStatsSubscription {
3061 channel: LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
3062 flags: MarketStatsFlags {
3063 mark_price: true,
3064 index_price: true,
3065 ..Default::default()
3066 },
3067 },
3068 );
3069 generations.insert(instrument_id, 7);
3070
3071 rollback_market_stats_subscription(&subscriptions, &generations, instrument_id, 7);
3072
3073 assert!(
3074 !subscriptions.contains_key(&instrument_id),
3075 "all flags share the failed underlying channel",
3076 );
3077 assert!(!generations.contains_key(&instrument_id));
3078 }
3079
3080 #[rstest]
3081 fn test_rollback_market_stats_subscription_keeps_replacement_generation() {
3082 let subscriptions = DashMap::new();
3083 let generations = DashMap::new();
3084 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
3085 let replacement = MarketStatsSubscription {
3086 channel: LighterWsChannel::MarketStats(LighterMarketSelection::Market(0)),
3087 flags: MarketStatsFlags {
3088 funding_rate: true,
3089 ..Default::default()
3090 },
3091 };
3092 subscriptions.insert(instrument_id, replacement.clone());
3093 generations.insert(instrument_id, 8);
3094
3095 rollback_market_stats_subscription(&subscriptions, &generations, instrument_id, 7);
3096
3097 assert_eq!(
3098 subscriptions
3099 .get(&instrument_id)
3100 .expect("replacement retained")
3101 .flags,
3102 replacement.flags,
3103 );
3104 assert_eq!(generations.get(&instrument_id).map(|value| *value), Some(8));
3105 }
3106
3107 fn create_data_client_for_test() -> LighterDataClient {
3108 create_data_client_with_receiver_for_test().0
3109 }
3110
3111 fn create_data_client_with_receiver_for_test() -> (
3112 LighterDataClient,
3113 tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
3114 ) {
3115 create_data_client_with_receiver_and_config_for_test(LighterDataClientConfig::default())
3116 }
3117
3118 fn create_data_client_with_receiver_and_config_for_test(
3119 mut config: LighterDataClientConfig,
3120 ) -> (
3121 LighterDataClient,
3122 tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
3123 ) {
3124 config.api_key_index = Some(5);
3125 config.account_index = Some(12_345);
3126 config.private_key = Some(PRIVATE_KEY_HEX.into());
3127 let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
3128 replace_data_event_sender(sender);
3129 let client = LighterDataClient::new(ClientId::new("LIGHTER"), config).unwrap();
3130 (client, receiver)
3131 }
3132
3133 async fn spawn_order_book_details_server() -> String {
3134 let app = Router::new().route("/api/v1/orderBookDetails", get(order_book_details));
3135 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3136 let addr = listener.local_addr().unwrap();
3137 tokio::spawn(async move {
3138 axum::serve(listener, app).await.unwrap();
3139 });
3140
3141 format!("http://{addr}")
3142 }
3143
3144 async fn spawn_fundings_server() -> String {
3145 let app = Router::new().route("/api/v1/fundings", get(fundings));
3146 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3147 let addr = listener.local_addr().unwrap();
3148 tokio::spawn(async move {
3149 axum::serve(listener, app).await.unwrap();
3150 });
3151
3152 format!("http://{addr}")
3153 }
3154
3155 async fn spawn_trades_server() -> String {
3156 spawn_trades_server_with_response(HTTP_RECENT_TRADES).await
3157 }
3158
3159 async fn spawn_trades_server_with_response(response_body: &'static str) -> String {
3160 spawn_trades_server_with_response_and_limit(response_body, 50).await
3161 }
3162
3163 async fn spawn_trades_server_with_response_and_limit(
3164 response_body: &'static str,
3165 expected_limit: u16,
3166 ) -> String {
3167 let app = Router::new().route(
3168 "/api/v1/recentTrades",
3169 get(
3170 move |Query(query): Query<LighterRecentTradesQuery>| async move {
3171 recent_trades_response(&query, response_body, expected_limit)
3172 },
3173 ),
3174 );
3175 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
3176 let addr = listener.local_addr().unwrap();
3177 tokio::spawn(async move {
3178 axum::serve(listener, app).await.unwrap();
3179 });
3180
3181 format!("http://{addr}")
3182 }
3183
3184 async fn order_book_details() -> Response {
3185 (StatusCode::OK, HTTP_ORDER_BOOK_DETAILS).into_response()
3186 }
3187
3188 async fn fundings(Query(query): Query<LighterFundingsQuery>) -> Response {
3189 assert_eq!(query.market_id, 0);
3190 assert_eq!(query.resolution, LighterFundingResolution::OneHour);
3191 assert_eq!(query.start_timestamp, 1_778_702_400_000);
3192 assert_eq!(query.end_timestamp, 1_778_706_000_000);
3193 assert_eq!(
3194 query.count_back,
3195 i64::from(crate::http::client::LIGHTER_FUNDINGS_MAX_LIMIT)
3196 );
3197 (StatusCode::OK, HTTP_FUNDINGS).into_response()
3198 }
3199
3200 fn recent_trades_response(
3201 query: &LighterRecentTradesQuery,
3202 response_body: &'static str,
3203 expected_limit: u16,
3204 ) -> Response {
3205 assert_eq!(query.market_id, 0);
3206 assert_eq!(query.limit, expected_limit);
3207 (StatusCode::OK, response_body).into_response()
3208 }
3209
3210 fn cache_test_instrument(
3211 client: &LighterDataClient,
3212 market_index: i64,
3213 venue_symbol: &str,
3214 product_type: LighterProductType,
3215 ) -> InstrumentId {
3216 let instrument_id = client
3217 .registry
3218 .insert(market_index, venue_symbol, product_type);
3219 let instrument = match product_type {
3220 LighterProductType::Perp => test_perp_instrument(instrument_id, venue_symbol),
3221 LighterProductType::Spot => test_spot_instrument(instrument_id, venue_symbol),
3222 };
3223
3224 client.instruments.rcu(|m| {
3225 m.insert(instrument_id, instrument.clone());
3226 });
3227
3228 instrument_id
3229 }
3230
3231 fn test_perp_instrument(instrument_id: InstrumentId, venue_symbol: &str) -> InstrumentAny {
3232 InstrumentAny::CryptoPerpetual(
3233 CryptoPerpetual::builder()
3234 .instrument_id(instrument_id)
3235 .raw_symbol(Symbol::new(format!("{venue_symbol}-PERP")))
3236 .base_currency(Currency::from(venue_symbol))
3237 .quote_currency(Currency::from("USDC"))
3238 .settlement_currency(Currency::from("USDC"))
3239 .is_inverse(false)
3240 .price_precision(2)
3241 .size_precision(4)
3242 .price_increment(Price::from("0.01"))
3243 .size_increment(Quantity::from("0.0001"))
3244 .ts_event(UnixNanos::default())
3245 .ts_init(UnixNanos::default())
3246 .build()
3247 .unwrap(),
3248 )
3249 }
3250
3251 fn test_spot_instrument(instrument_id: InstrumentId, venue_symbol: &str) -> InstrumentAny {
3252 InstrumentAny::CurrencyPair(
3253 CurrencyPair::builder()
3254 .instrument_id(instrument_id)
3255 .raw_symbol(Symbol::new(format!("{venue_symbol}-SPOT")))
3256 .base_currency(Currency::from(venue_symbol))
3257 .quote_currency(Currency::from("USDC"))
3258 .price_precision(2)
3259 .size_precision(4)
3260 .price_increment(Price::from("0.01"))
3261 .size_increment(Quantity::from("0.0001"))
3262 .ts_event(UnixNanos::default())
3263 .ts_init(UnixNanos::default())
3264 .build()
3265 .unwrap(),
3266 )
3267 }
3268
3269 fn unsupported_three_minute_bar_type() -> BarType {
3270 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), *LIGHTER_VENUE);
3271 BarType::new(
3272 instrument_id,
3273 BarSpecification::new(3, BarAggregation::Minute, PriceType::Last),
3274 AggregationSource::External,
3275 )
3276 }
3277}