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