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