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