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