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