Skip to main content

nautilus_lighter/data/
mod.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Live data client for the Lighter adapter.
17
18use 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    /// Creates a new [`LighterDataClient`] instance.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the HTTP client fails to initialize.
130    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            // Mirror `has_credentials()`: a blank or whitespace-only `private_key`
139            // config value falls back to the env var rather than overriding it.
140            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(&registry));
174
175        let ws_client = Self::create_ws_client(&config, Arc::clone(&registry), &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    /// Returns `true` when the data client holds resolved Lighter credentials.
209    #[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    // Biased select drops an in-flight task on cancellation before it emits a late DataEvent
259    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        // Connect on a clone so the resulting `out_rx` (and inner handler
384        // task handle) live on the consumer; transfer the handle back to
385        // `self.ws_client` so disconnect() can await it.
386        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                    // Prefer cancellation so a buffered frame is not forwarded after cancel
436                    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                            // The data client does not consume execution-side
491                            // reports; the execution client subscribes to its
492                            // own clone of the WebSocket and routes them.
493                            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                    // The underlying channel never became active, so clear every request
725                    // piggybacked on this generation. A newer replacement is left intact.
726                    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        // Hold the shard lock across removal so a concurrent activate cannot re-add an erased flag
744        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
1826/// Returns an error if `book_type` is not [`BookType::L2_MBP`].
1827///
1828/// Lighter publishes only level-aggregated book updates, so any other book
1829/// type cannot be served by the WebSocket feed.
1830fn 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        // Prevent the unconnected test client from asynchronously rolling back local flags
2077        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        // Cancel before the spawn so the biased select drops the future before it can send
2934        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        // In-flight task (never-released barrier) that would emit; disconnect must drop it first
2976        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        // The task ignores cancellation, so shutdown_tasks must abort it after the grace timeout.
3009        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}