Skip to main content

nautilus_lighter/http/
client.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//! Raw and domain HTTP clients for Lighter REST endpoints.
17
18use std::{collections::HashMap, sync::Arc};
19
20use chrono::{DateTime, Utc};
21use nautilus_core::{
22    AtomicTime, UnixNanos, consts::NAUTILUS_USER_AGENT, time::get_atomic_clock_realtime,
23};
24use nautilus_model::{
25    data::{Bar, BarType, FundingRateUpdate, OrderBookDeltas, TradeTick},
26    identifiers::InstrumentId,
27    instruments::{Instrument, InstrumentAny},
28};
29use nautilus_network::{
30    http::{HttpClient, HttpResponse, Method, USER_AGENT},
31    ratelimiter::quota::Quota,
32    retry::{RetryManager, create_http_retry_manager},
33};
34use serde::{Serialize, de::DeserializeOwned};
35
36use crate::{
37    common::{
38        enums::{
39            LighterCandleResolution, LighterEnvironment, LighterFundingResolution,
40            LighterMarketStatus,
41        },
42        rate_limit::{
43            LIGHTER_REST_BUCKET, LIGHTER_REST_QUOTA, LighterTxRateLimiter, await_tx_quota,
44        },
45        symbol::MarketRegistry,
46        urls::lighter_http_base_url,
47    },
48    http::{
49        error::{
50            LighterHttpError, LighterHttpResult, create_lighter_http_timeout_error,
51            should_retry_lighter_http_error,
52        },
53        models::{
54            LighterAccountDetail, LighterAccountsResponse, LighterCandle, LighterCandles,
55            LighterFundings, LighterMakerOnlyApiKeys, LighterNextNonce, LighterOrderBookDetails,
56            LighterOrderBookOrders, LighterOrderBooks, LighterOrders, LighterResultCode,
57            LighterSendTxBatchRequest, LighterSendTxBatchResponse, LighterSendTxRequest,
58            LighterSendTxResponse, LighterTrade, LighterTrades,
59        },
60        parse::{
61            parse_candle_bar, parse_funding_rate_update,
62            parse_order_book_details_instruments_with_status, parse_order_book_snapshot,
63            parse_trade_tick, register_order_books, register_perp_order_book_details,
64            register_spot_order_book_details,
65        },
66        query::{
67            LighterAccountActiveOrdersQuery, LighterAccountInactiveOrdersQuery,
68            LighterAccountLookup, LighterAccountQuery, LighterCandlesQuery, LighterFundingsQuery,
69            LighterMakerOnlyApiKeysQuery, LighterNextNonceQuery, LighterOrderBookDetailsQuery,
70            LighterOrderBookOrdersQuery, LighterOrderBooksQuery, LighterRecentTradesQuery,
71            LighterTradesQuery,
72        },
73    },
74};
75
76const API_V1: &str = "/api/v1";
77const ENDPOINT_ACCOUNT: &str = "/api/v1/account";
78const ENDPOINT_ACCOUNT_ACTIVE_ORDERS: &str = "/api/v1/accountActiveOrders";
79const ENDPOINT_ACCOUNT_INACTIVE_ORDERS: &str = "/api/v1/accountInactiveOrders";
80const ENDPOINT_CANDLES: &str = "/api/v1/candles";
81const ENDPOINT_FUNDINGS: &str = "/api/v1/fundings";
82const ENDPOINT_MAKER_ONLY_API_KEYS: &str = "/api/v1/getMakerOnlyApiKeys";
83const ENDPOINT_NEXT_NONCE: &str = "/api/v1/nextNonce";
84const ENDPOINT_ORDER_BOOK_DETAILS: &str = "/api/v1/orderBookDetails";
85const ENDPOINT_ORDER_BOOK_ORDERS: &str = "/api/v1/orderBookOrders";
86const ENDPOINT_ORDER_BOOKS: &str = "/api/v1/orderBooks";
87const ENDPOINT_RECENT_TRADES: &str = "/api/v1/recentTrades";
88const ENDPOINT_SEND_TX: &str = "/api/v1/sendTx";
89const ENDPOINT_SEND_TX_BATCH: &str = "/api/v1/sendTxBatch";
90const ENDPOINT_TRADES: &str = "/api/v1/trades";
91const HEADER_AUTHORIZATION: &str = "authorization";
92const MULTIPART_BOUNDARY: &str = "nautilus-lighter-form-boundary";
93
94/// Maximum page size accepted by Lighter REST list endpoints (`/api/v1/trades`,
95/// `/api/v1/accountInactiveOrders`). Values above this trigger `20001 invalid
96/// param` from the venue, so reconciliation paginates at this cap and follows
97/// `next_cursor` until the response is empty.
98pub const LIGHTER_REST_PAGE_SIZE: u16 = 100;
99pub const LIGHTER_CANDLES_MAX_LIMIT: u16 = 500;
100
101/// Maximum rows returned per `/api/v1/fundings` call (the venue per-call cap).
102pub const LIGHTER_FUNDINGS_MAX_LIMIT: u16 = 100;
103
104const DEFAULT_BARS_LIMIT: usize = LIGHTER_CANDLES_MAX_LIMIT as usize;
105const DEFAULT_FUNDING_RATES_LIMIT: usize = 100;
106const MAX_BAR_REQUEST_PAGES: usize = 500;
107const MAX_FUNDING_REQUEST_PAGES: usize = 500;
108
109trait LighterResponseCheck {
110    fn response_code(&self) -> i32;
111    fn response_message(&self) -> Option<&str>;
112}
113
114macro_rules! impl_lighter_response_check {
115    ($($ty:ty),+ $(,)?) => {
116        $(
117            impl LighterResponseCheck for $ty {
118                fn response_code(&self) -> i32 {
119                    self.code
120                }
121
122                fn response_message(&self) -> Option<&str> {
123                    self.message.as_deref()
124                }
125            }
126        )+
127    };
128}
129
130impl_lighter_response_check!(
131    LighterAccountsResponse,
132    LighterCandles,
133    LighterFundings,
134    LighterMakerOnlyApiKeys,
135    LighterNextNonce,
136    LighterOrderBookDetails,
137    LighterOrderBookOrders,
138    LighterOrderBooks,
139    LighterOrders,
140    LighterResultCode,
141    LighterSendTxBatchResponse,
142    LighterSendTxResponse,
143    LighterTrades,
144);
145
146/// Raw HTTP client for Lighter REST API operations.
147///
148/// This client owns the transport, base URL, default headers, and rate limit. Methods map directly
149/// to venue endpoints and return venue response models without converting to Nautilus domain types.
150#[derive(Clone, Debug)]
151pub struct LighterRawHttpClient {
152    base_url: String,
153    environment: LighterEnvironment,
154    client: HttpClient,
155    retry_manager: RetryManager<LighterHttpError>,
156    tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
157}
158
159impl Default for LighterRawHttpClient {
160    fn default() -> Self {
161        Self::new(LighterEnvironment::Mainnet, None, 60, None)
162            .expect("failed to create default Lighter raw HTTP client")
163    }
164}
165
166impl LighterRawHttpClient {
167    /// Creates a new [`LighterRawHttpClient`].
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if the underlying HTTP client cannot be created.
172    pub fn new(
173        environment: LighterEnvironment,
174        base_url: Option<String>,
175        timeout_secs: u64,
176        proxy_url: Option<String>,
177    ) -> LighterHttpResult<Self> {
178        Self::new_with_quotas(
179            environment,
180            base_url,
181            timeout_secs,
182            proxy_url,
183            *LIGHTER_REST_QUOTA,
184            None,
185        )
186    }
187
188    /// Creates a new [`LighterRawHttpClient`] with an explicit read quota and an
189    /// optional shared transaction limiter.
190    ///
191    /// `default_quota` governs the REST read bucket ([`LIGHTER_REST_BUCKET`]),
192    /// resolved from the client's configured `rest_quota_per_min` (detected tier
193    /// only logs hints). `tx_rate_limiter` paces `sendTx` / `sendTxBatch`; the
194    /// execution client shares one limiter across this and the WebSocket
195    /// `sendTx` path so their combined rate honours the single venue tx bucket.
196    /// The data client passes `None` (it sends no transactions).
197    ///
198    /// # Errors
199    ///
200    /// Returns an error if the underlying HTTP client cannot be created.
201    pub fn new_with_quotas(
202        environment: LighterEnvironment,
203        base_url: Option<String>,
204        timeout_secs: u64,
205        proxy_url: Option<String>,
206        default_quota: Quota,
207        tx_rate_limiter: Option<Arc<LighterTxRateLimiter>>,
208    ) -> LighterHttpResult<Self> {
209        let base_url = base_url
210            .unwrap_or_else(|| lighter_http_base_url(environment).to_string())
211            .trim_end_matches('/')
212            .to_string();
213
214        Ok(Self {
215            base_url,
216            environment,
217            client: HttpClient::new(
218                Self::default_headers(),
219                vec![],
220                vec![],
221                Some(default_quota),
222                Some(timeout_secs),
223                proxy_url,
224            )?,
225            retry_manager: create_http_retry_manager(),
226            tx_rate_limiter,
227        })
228    }
229
230    /// Returns the configured REST base URL.
231    #[must_use]
232    pub fn base_url(&self) -> &str {
233        self.base_url.as_str()
234    }
235
236    /// Returns the configured Lighter environment.
237    #[must_use]
238    pub const fn environment(&self) -> LighterEnvironment {
239        self.environment
240    }
241
242    /// Overrides the REST base URL. Intended for mock-server tests.
243    pub fn set_base_url(&mut self, base_url: &str) {
244        self.base_url = base_url.trim_end_matches('/').to_string();
245    }
246
247    /// Overrides the retry manager. Intended for mock-server tests that need
248    /// shorter backoff than [`create_http_retry_manager`] produces.
249    pub fn set_retry_manager(&mut self, retry_manager: RetryManager<LighterHttpError>) {
250        self.retry_manager = retry_manager;
251    }
252
253    /// Calls `GET /api/v1/orderBooks`.
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if the request fails or the response is invalid.
258    pub async fn get_order_books(
259        &self,
260        query: &LighterOrderBooksQuery,
261    ) -> LighterHttpResult<LighterOrderBooks> {
262        self.send_get_request(ENDPOINT_ORDER_BOOKS, Some(query))
263            .await
264    }
265
266    /// Calls `GET /api/v1/orderBookDetails`.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error if the request fails or the response is invalid.
271    pub async fn get_order_book_details(
272        &self,
273        query: &LighterOrderBookDetailsQuery,
274    ) -> LighterHttpResult<LighterOrderBookDetails> {
275        self.send_get_request(ENDPOINT_ORDER_BOOK_DETAILS, Some(query))
276            .await
277    }
278
279    /// Calls `GET /api/v1/orderBookOrders`.
280    ///
281    /// # Errors
282    ///
283    /// Returns an error if the request fails or the response is invalid.
284    pub async fn get_order_book_orders(
285        &self,
286        query: &LighterOrderBookOrdersQuery,
287    ) -> LighterHttpResult<LighterOrderBookOrders> {
288        self.send_get_request(ENDPOINT_ORDER_BOOK_ORDERS, Some(query))
289            .await
290    }
291
292    /// Calls `GET /api/v1/recentTrades`.
293    ///
294    /// # Errors
295    ///
296    /// Returns an error if the request fails or the response is invalid.
297    pub async fn get_recent_trades(
298        &self,
299        query: &LighterRecentTradesQuery,
300    ) -> LighterHttpResult<LighterTrades> {
301        self.send_get_request(ENDPOINT_RECENT_TRADES, Some(query))
302            .await
303    }
304
305    /// Calls `GET /api/v1/trades`.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the request fails or the response is invalid.
310    pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
311        self.send_get_request(ENDPOINT_TRADES, Some(query)).await
312    }
313
314    /// Calls `GET /api/v1/candles`.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the request fails or the response is invalid.
319    pub async fn get_candles(
320        &self,
321        query: &LighterCandlesQuery,
322    ) -> LighterHttpResult<LighterCandles> {
323        self.send_get_request(ENDPOINT_CANDLES, Some(query)).await
324    }
325
326    /// Calls `GET /api/v1/fundings`.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the request fails or the response is invalid.
331    pub async fn get_fundings(
332        &self,
333        query: &LighterFundingsQuery,
334    ) -> LighterHttpResult<LighterFundings> {
335        self.send_get_request(ENDPOINT_FUNDINGS, Some(query)).await
336    }
337
338    /// Calls `GET /api/v1/account`.
339    ///
340    /// # Errors
341    ///
342    /// Returns an error if the request fails or the response is invalid.
343    pub async fn get_account(
344        &self,
345        query: &LighterAccountQuery,
346    ) -> LighterHttpResult<LighterAccountsResponse> {
347        self.send_get_request(ENDPOINT_ACCOUNT, Some(query)).await
348    }
349
350    /// Calls `GET /api/v1/accountActiveOrders`.
351    ///
352    /// # Errors
353    ///
354    /// Returns an error if the request fails or the response is invalid.
355    pub async fn get_account_active_orders(
356        &self,
357        query: &LighterAccountActiveOrdersQuery,
358    ) -> LighterHttpResult<LighterOrders> {
359        self.send_get_request(ENDPOINT_ACCOUNT_ACTIVE_ORDERS, Some(query))
360            .await
361    }
362
363    /// Calls `GET /api/v1/accountInactiveOrders`.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if the request fails or the response is invalid.
368    pub async fn get_account_inactive_orders(
369        &self,
370        query: &LighterAccountInactiveOrdersQuery,
371    ) -> LighterHttpResult<LighterOrders> {
372        self.send_get_request(ENDPOINT_ACCOUNT_INACTIVE_ORDERS, Some(query))
373            .await
374    }
375
376    /// Calls `GET /api/v1/nextNonce`.
377    ///
378    /// # Errors
379    ///
380    /// Returns an error if the request fails or the response is invalid.
381    pub async fn get_next_nonce(
382        &self,
383        query: &LighterNextNonceQuery,
384    ) -> LighterHttpResult<LighterNextNonce> {
385        self.send_get_request(ENDPOINT_NEXT_NONCE, Some(query))
386            .await
387    }
388
389    /// Calls `GET /api/v1/getMakerOnlyApiKeys`.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if the request fails or the response is invalid.
394    pub async fn get_maker_only_api_keys(
395        &self,
396        query: &LighterMakerOnlyApiKeysQuery,
397    ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
398        let params = LighterMakerOnlyApiKeysParams {
399            account_index: query.account_index,
400        };
401        let headers = query
402            .authorization
403            .as_ref()
404            .or(query.auth.as_ref())
405            .map(|auth| HashMap::from([(HEADER_AUTHORIZATION.to_string(), auth.clone())]));
406        self.send_get_request_with_headers(ENDPOINT_MAKER_ONLY_API_KEYS, Some(&params), headers)
407            .await
408    }
409
410    /// Calls `POST /api/v1/sendTx`.
411    ///
412    /// # Errors
413    ///
414    /// Returns an error if the request fails or the response is invalid.
415    pub async fn send_tx(
416        &self,
417        request: &LighterSendTxRequest,
418    ) -> LighterHttpResult<LighterSendTxResponse> {
419        let fields = request.form_fields();
420        self.send_post_form(ENDPOINT_SEND_TX, &fields).await
421    }
422
423    /// Calls `POST /api/v1/sendTxBatch`.
424    ///
425    /// # Errors
426    ///
427    /// Returns an error if the request fails or the response is invalid.
428    pub async fn send_tx_batch(
429        &self,
430        request: &LighterSendTxBatchRequest,
431    ) -> LighterHttpResult<LighterSendTxBatchResponse> {
432        let fields = request.form_fields();
433        self.send_post_form(ENDPOINT_SEND_TX_BATCH, &fields).await
434    }
435
436    async fn send_get_request<T, P>(
437        &self,
438        endpoint: &str,
439        params: Option<&P>,
440    ) -> LighterHttpResult<T>
441    where
442        T: DeserializeOwned + LighterResponseCheck,
443        P: Serialize,
444    {
445        self.send_get_request_with_headers(endpoint, params, None)
446            .await
447    }
448
449    async fn send_get_request_with_headers<T, P>(
450        &self,
451        endpoint: &str,
452        params: Option<&P>,
453        headers: Option<HashMap<String, String>>,
454    ) -> LighterHttpResult<T>
455    where
456        T: DeserializeOwned + LighterResponseCheck,
457        P: Serialize,
458    {
459        let url = self.url(endpoint);
460        let rate_limit_keys = Self::rate_limit_keys(endpoint);
461        self.retry_manager
462            .execute_with_retry(
463                endpoint,
464                || {
465                    let url = url.clone();
466                    let rate_limit_keys = rate_limit_keys.clone();
467                    let headers = headers.clone();
468
469                    async move {
470                        let response = self
471                            .client
472                            .request_with_params(
473                                Method::GET,
474                                url,
475                                params,
476                                headers,
477                                None,
478                                None,
479                                Some(rate_limit_keys),
480                            )
481                            .await?;
482                        Self::parse_response(&response)
483                    }
484                },
485                should_retry_lighter_http_error,
486                create_lighter_http_timeout_error,
487            )
488            .await
489    }
490
491    // Single-shot: sendTx / sendTxBatch carry a signed nonce; transport-layer
492    // retry could double-submit if the original landed and only the ack was lost.
493    async fn send_post_form<T>(
494        &self,
495        endpoint: &str,
496        fields: &[(&str, String)],
497    ) -> LighterHttpResult<T>
498    where
499        T: DeserializeOwned + LighterResponseCheck,
500    {
501        // send_post_form only carries sendTx / sendTxBatch. With a shared tx
502        // limiter (execution client) pace on it and pass no REST keys, so the
503        // read quota does not also cap transactions. Without one (e.g. the
504        // integrator-revoke utility) fall back to the internal REST limiter so
505        // the request is still bounded rather than unthrottled.
506        let rate_keys = match &self.tx_rate_limiter {
507            Some(limiter) => {
508                await_tx_quota(limiter).await;
509                None
510            }
511            None => Some(Self::rate_limit_keys(endpoint)),
512        };
513
514        let response = self
515            .client
516            .request(
517                Method::POST,
518                self.url(endpoint),
519                None,
520                Some(multipart_headers()),
521                Some(multipart_form_bytes(fields)),
522                None,
523                rate_keys,
524            )
525            .await?;
526
527        Self::parse_response(&response)
528    }
529
530    fn parse_response<T>(response: &HttpResponse) -> LighterHttpResult<T>
531    where
532        T: DeserializeOwned + LighterResponseCheck,
533    {
534        if !response.status.is_success() {
535            let status = response.status.as_u16();
536            let body = String::from_utf8_lossy(&response.body).to_string();
537
538            // Status-first: a `{code,message}` body must not override the
539            // retry decision for 5xx / 429.
540            if status >= 500 {
541                return Err(LighterHttpError::Http { status, body });
542            }
543
544            // HTTP 405 is a Lighter rate-limit status like 429 per the docs, not a method error
545            if status == 429 || status == 405 {
546                return Err(LighterHttpError::RateLimit(body));
547            }
548
549            if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body)
550                && result.code != 200
551            {
552                Self::result_code_error(result.code, result.message.as_deref(), "HTTP error")?;
553            }
554
555            return Err(LighterHttpError::Http { status, body });
556        }
557
558        if let Ok(result) = serde_json::from_slice::<LighterResultCode>(&response.body) {
559            Self::check_response(&result)?;
560        }
561
562        let payload: T = serde_json::from_slice(&response.body)?;
563        Self::check_response(&payload)?;
564        Ok(payload)
565    }
566
567    fn check_response<T: LighterResponseCheck>(payload: &T) -> LighterHttpResult<()> {
568        Self::result_code_error(
569            payload.response_code(),
570            payload.response_message(),
571            "Lighter request failed",
572        )
573    }
574
575    fn result_code_error(
576        code: i32,
577        message: Option<&str>,
578        default_message: &str,
579    ) -> LighterHttpResult<()> {
580        match code {
581            200 => Ok(()),
582            405 | 429 => Err(LighterHttpError::RateLimit(
583                message.unwrap_or("Lighter rate limit exceeded").to_string(),
584            )),
585            code => Err(venue_error(code, message, default_message)),
586        }
587    }
588
589    fn url(&self, endpoint: &str) -> String {
590        format!("{}{}", self.base_url, endpoint)
591    }
592
593    fn rate_limit_keys(endpoint: &str) -> Vec<String> {
594        let route = endpoint.strip_prefix(API_V1).unwrap_or(endpoint);
595        vec![
596            LIGHTER_REST_BUCKET.to_string(),
597            format!("lighter:{}", route.trim_start_matches('/')),
598        ]
599    }
600
601    fn default_headers() -> HashMap<String, String> {
602        HashMap::from([(USER_AGENT.to_string(), NAUTILUS_USER_AGENT.to_string())])
603    }
604}
605
606#[derive(Serialize)]
607struct LighterMakerOnlyApiKeysParams {
608    account_index: i64,
609}
610
611fn multipart_headers() -> HashMap<String, String> {
612    HashMap::from([
613        ("Accept".to_string(), "application/json".to_string()),
614        (
615            "Content-Type".to_string(),
616            format!("multipart/form-data; boundary={MULTIPART_BOUNDARY}"),
617        ),
618    ])
619}
620
621fn multipart_form_bytes(fields: &[(&str, String)]) -> Vec<u8> {
622    let mut body = String::new();
623    for (name, value) in fields {
624        body.push_str("--");
625        body.push_str(MULTIPART_BOUNDARY);
626        body.push_str("\r\nContent-Disposition: form-data; name=\"");
627        body.push_str(name);
628        body.push_str("\"\r\n\r\n");
629        body.push_str(value);
630        body.push_str("\r\n");
631    }
632    body.push_str("--");
633    body.push_str(MULTIPART_BOUNDARY);
634    body.push_str("--\r\n");
635    body.into_bytes()
636}
637
638/// Domain HTTP client for Lighter REST operations.
639///
640/// This client wraps [`LighterRawHttpClient`] and converts selected endpoint responses into
641/// Nautilus domain data. Market metadata calls also populate the shared [`MarketRegistry`].
642#[derive(Clone, Debug)]
643pub struct LighterHttpClient {
644    pub(crate) inner: Arc<LighterRawHttpClient>,
645    market_registry: Arc<MarketRegistry>,
646    clock: &'static AtomicTime,
647}
648
649impl Default for LighterHttpClient {
650    fn default() -> Self {
651        Self::new(LighterEnvironment::Mainnet, None, 60, None)
652            .expect("failed to create default Lighter HTTP client")
653    }
654}
655
656impl LighterHttpClient {
657    /// Creates a new [`LighterHttpClient`].
658    ///
659    /// # Errors
660    ///
661    /// Returns an error if the underlying raw HTTP client cannot be created.
662    pub fn new(
663        environment: LighterEnvironment,
664        base_url: Option<String>,
665        timeout_secs: u64,
666        proxy_url: Option<String>,
667    ) -> LighterHttpResult<Self> {
668        let raw_client = LighterRawHttpClient::new(environment, base_url, timeout_secs, proxy_url)?;
669        Ok(Self::from_raw(raw_client))
670    }
671
672    /// Wraps an existing raw HTTP client.
673    #[must_use]
674    pub fn from_raw(raw_client: LighterRawHttpClient) -> Self {
675        Self::from_raw_with_registry(raw_client, Arc::new(MarketRegistry::new()))
676    }
677
678    /// Wraps an existing raw HTTP client and shared market registry.
679    #[must_use]
680    pub fn from_raw_with_registry(
681        raw_client: LighterRawHttpClient,
682        market_registry: Arc<MarketRegistry>,
683    ) -> Self {
684        Self {
685            inner: Arc::new(raw_client),
686            market_registry,
687            clock: get_atomic_clock_realtime(),
688        }
689    }
690
691    /// Returns the configured REST base URL.
692    #[must_use]
693    pub fn base_url(&self) -> &str {
694        self.inner.base_url()
695    }
696
697    /// Returns the configured Lighter environment.
698    #[must_use]
699    pub fn environment(&self) -> LighterEnvironment {
700        self.inner.environment()
701    }
702
703    /// Returns the shared market registry used by this client.
704    #[must_use]
705    pub fn market_registry(&self) -> Arc<MarketRegistry> {
706        self.market_registry.clone()
707    }
708
709    /// Overrides the REST base URL. Intended for mock-server tests.
710    ///
711    /// # Panics
712    ///
713    /// Panics if the raw client is shared by another [`Arc`].
714    pub fn set_base_url(&mut self, base_url: &str) {
715        Arc::get_mut(&mut self.inner)
716            .expect("cannot override URL: raw client is shared")
717            .set_base_url(base_url);
718    }
719
720    /// Calls `GET /api/v1/orderBooks` and registers returned markets.
721    ///
722    /// # Errors
723    ///
724    /// Returns an error if the request fails or the response is invalid.
725    pub async fn get_order_books(
726        &self,
727        query: &LighterOrderBooksQuery,
728    ) -> LighterHttpResult<LighterOrderBooks> {
729        let response = self.inner.get_order_books(query).await?;
730        register_order_books(&self.market_registry, &response.order_books);
731        Ok(response)
732    }
733
734    /// Calls `GET /api/v1/orderBookDetails` and registers returned markets.
735    ///
736    /// # Errors
737    ///
738    /// Returns an error if the request fails or the response is invalid.
739    pub async fn get_order_book_details(
740        &self,
741        query: &LighterOrderBookDetailsQuery,
742    ) -> LighterHttpResult<LighterOrderBookDetails> {
743        let response = self.inner.get_order_book_details(query).await?;
744        register_perp_order_book_details(&self.market_registry, &response.order_book_details);
745        register_spot_order_book_details(&self.market_registry, &response.spot_order_book_details);
746        Ok(response)
747    }
748
749    /// Calls `GET /api/v1/orderBookOrders`.
750    ///
751    /// # Errors
752    ///
753    /// Returns an error if the request fails or the response is invalid.
754    pub async fn get_order_book_orders(
755        &self,
756        query: &LighterOrderBookOrdersQuery,
757    ) -> LighterHttpResult<LighterOrderBookOrders> {
758        self.inner.get_order_book_orders(query).await
759    }
760
761    /// Calls `GET /api/v1/recentTrades`.
762    ///
763    /// # Errors
764    ///
765    /// Returns an error if the request fails or the response is invalid.
766    pub async fn get_recent_trades(
767        &self,
768        query: &LighterRecentTradesQuery,
769    ) -> LighterHttpResult<LighterTrades> {
770        self.inner.get_recent_trades(query).await
771    }
772
773    /// Calls `GET /api/v1/trades`.
774    ///
775    /// # Errors
776    ///
777    /// Returns an error if the request fails or the response is invalid.
778    pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
779        self.inner.get_trades(query).await
780    }
781
782    /// Calls `GET /api/v1/candles`.
783    ///
784    /// # Errors
785    ///
786    /// Returns an error if the request fails or the response is invalid.
787    pub async fn get_candles(
788        &self,
789        query: &LighterCandlesQuery,
790    ) -> LighterHttpResult<LighterCandles> {
791        self.inner.get_candles(query).await
792    }
793
794    /// Calls `GET /api/v1/fundings`.
795    ///
796    /// # Errors
797    ///
798    /// Returns an error if the request fails or the response is invalid.
799    pub async fn get_fundings(
800        &self,
801        query: &LighterFundingsQuery,
802    ) -> LighterHttpResult<LighterFundings> {
803        self.inner.get_fundings(query).await
804    }
805
806    /// Fetches the account row for `account_index` via `GET /api/v1/account`.
807    ///
808    /// # Errors
809    ///
810    /// Returns an error if the request fails, the response is invalid, or the
811    /// venue returns no account for the index.
812    pub async fn get_account_detail(
813        &self,
814        account_index: i64,
815    ) -> LighterHttpResult<LighterAccountDetail> {
816        let query = LighterAccountQuery {
817            by: LighterAccountLookup::Index,
818            value: account_index.to_string(),
819        };
820        self.inner
821            .get_account(&query)
822            .await?
823            .accounts
824            .into_iter()
825            .next()
826            .ok_or_else(|| {
827                LighterHttpError::Parse(format!("no account returned for index {account_index}"))
828            })
829    }
830
831    /// Calls `GET /api/v1/accountActiveOrders`.
832    ///
833    /// # Errors
834    ///
835    /// Returns an error if the request fails or the response is invalid.
836    pub async fn get_account_active_orders(
837        &self,
838        query: &LighterAccountActiveOrdersQuery,
839    ) -> LighterHttpResult<LighterOrders> {
840        self.inner.get_account_active_orders(query).await
841    }
842
843    /// Calls `GET /api/v1/accountInactiveOrders`.
844    ///
845    /// # Errors
846    ///
847    /// Returns an error if the request fails or the response is invalid.
848    pub async fn get_account_inactive_orders(
849        &self,
850        query: &LighterAccountInactiveOrdersQuery,
851    ) -> LighterHttpResult<LighterOrders> {
852        self.inner.get_account_inactive_orders(query).await
853    }
854
855    /// Calls `GET /api/v1/nextNonce` for `(account_index, api_key_index)`.
856    ///
857    /// # Errors
858    ///
859    /// Returns an error if the request fails or the response is invalid.
860    pub async fn get_next_nonce(
861        &self,
862        account_index: i64,
863        api_key_index: u8,
864    ) -> LighterHttpResult<LighterNextNonce> {
865        let query = LighterNextNonceQuery {
866            account_index,
867            api_key_index,
868        };
869        self.inner.get_next_nonce(&query).await
870    }
871
872    /// Calls `GET /api/v1/getMakerOnlyApiKeys` for `account_index`.
873    ///
874    /// `auth_token` is the canonical Lighter auth string minted from the
875    /// caller's credential.
876    ///
877    /// # Errors
878    ///
879    /// Returns an error if the request fails or the response is invalid.
880    pub async fn get_maker_only_api_keys(
881        &self,
882        account_index: i64,
883        auth_token: impl Into<String>,
884    ) -> LighterHttpResult<LighterMakerOnlyApiKeys> {
885        let query = LighterMakerOnlyApiKeysQuery {
886            authorization: Some(auth_token.into()),
887            auth: None,
888            account_index,
889        };
890        self.inner.get_maker_only_api_keys(&query).await
891    }
892
893    /// Calls `POST /api/v1/sendTx`.
894    ///
895    /// # Errors
896    ///
897    /// Returns an error if the request fails or the response is invalid.
898    pub async fn send_tx(
899        &self,
900        request: &LighterSendTxRequest,
901    ) -> LighterHttpResult<LighterSendTxResponse> {
902        self.inner.send_tx(request).await
903    }
904
905    /// Calls `POST /api/v1/sendTxBatch`.
906    ///
907    /// # Errors
908    ///
909    /// Returns an error if the request fails or the response is invalid.
910    pub async fn send_tx_batch(
911        &self,
912        request: &LighterSendTxBatchRequest,
913    ) -> LighterHttpResult<LighterSendTxBatchResponse> {
914        self.inner.send_tx_batch(request).await
915    }
916
917    /// Requests recent trades for an instrument and parses them into [`TradeTick`]s.
918    ///
919    /// # Errors
920    ///
921    /// Returns an error if the instrument has not been registered, the request fails, or a trade
922    /// cannot be parsed.
923    pub async fn request_recent_trades(
924        &self,
925        instrument: &InstrumentAny,
926        limit: u16,
927    ) -> LighterHttpResult<Vec<TradeTick>> {
928        let market_id = self.market_index(instrument)?;
929        let query = LighterRecentTradesQuery { market_id, limit };
930        let response = self.inner.get_recent_trades(&query).await?;
931        self.parse_trade_ticks(&response.trades, instrument)
932    }
933
934    /// Requests historical trades and parses them into [`TradeTick`]s.
935    ///
936    /// If `query.market_id` is `None`, the value is resolved from the shared market registry.
937    ///
938    /// # Errors
939    ///
940    /// Returns an error if the instrument has not been registered, the request fails, or a trade
941    /// cannot be parsed.
942    pub async fn request_trades(
943        &self,
944        instrument: &InstrumentAny,
945        mut query: LighterTradesQuery,
946    ) -> LighterHttpResult<Vec<TradeTick>> {
947        if query.market_id.is_none() {
948            query.market_id = Some(self.market_index(instrument)?);
949        }
950        let response = self.inner.get_trades(&query).await?;
951        self.parse_trade_ticks(&response.trades, instrument)
952    }
953
954    /// Requests historical candles and parses them into Nautilus [`Bar`]s.
955    ///
956    /// # Errors
957    ///
958    /// Returns an error if the instrument has not been registered, the bar
959    /// type is unsupported, the request fails, or a candle cannot be parsed.
960    pub async fn request_bars(
961        &self,
962        instrument: &InstrumentAny,
963        bar_type: BarType,
964        start: Option<DateTime<Utc>>,
965        end: Option<DateTime<Utc>>,
966        limit: Option<u32>,
967    ) -> LighterHttpResult<Vec<Bar>> {
968        let market_id = self.market_index(instrument)?;
969        let resolution = LighterCandleResolution::try_from(&bar_type)?;
970        let interval_ms = resolution.interval_millis();
971        let now = Utc::now();
972
973        if let (Some(start), Some(end)) = (start, end)
974            && start >= end
975        {
976            return Err(LighterHttpError::Parse(format!(
977                "invalid bar request range: start={start}, end={end}",
978            )));
979        }
980
981        let end = end.unwrap_or(now).min(now);
982
983        if let Some(start) = start
984            && start >= end
985        {
986            return Ok(Vec::new());
987        }
988
989        let requested_limit = limit.filter(|n| *n > 0).map(|n| n as usize);
990        let target_limit = requested_limit.unwrap_or(DEFAULT_BARS_LIMIT);
991        let start_was_unspecified = start.is_none();
992        let end_ms = end.timestamp_millis().max(0);
993        let now_ms = now.timestamp_millis();
994
995        if end_ms == 0 {
996            return Ok(Vec::new());
997        }
998
999        let start_ms = start.map_or_else(
1000            || {
1001                let lookback_bars = target_limit.saturating_add(1);
1002                let lookback_bars = i64::try_from(lookback_bars).unwrap_or(i64::MAX);
1003                let lookback_ms = interval_ms.saturating_mul(lookback_bars);
1004                end_ms.saturating_sub(lookback_ms)
1005            },
1006            |dt| dt.timestamp_millis().max(0),
1007        );
1008
1009        if start_ms >= end_ms {
1010            return Ok(Vec::new());
1011        }
1012
1013        let mut bars = Vec::new();
1014        let mut cursor_ms = start_ms;
1015        let mut pages = 0_usize;
1016        let page_span_ms = interval_ms.saturating_mul(i64::from(LIGHTER_CANDLES_MAX_LIMIT));
1017
1018        while cursor_ms < end_ms && pages < MAX_BAR_REQUEST_PAGES {
1019            if !start_was_unspecified
1020                && let Some(limit) = requested_limit
1021                && bars.len() >= limit
1022            {
1023                break;
1024            }
1025
1026            let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1027            if window_end_ms <= cursor_ms {
1028                break;
1029            }
1030
1031            let query = LighterCandlesQuery {
1032                market_id,
1033                resolution,
1034                start_timestamp: cursor_ms,
1035                end_timestamp: window_end_ms,
1036                count_back: i64::from(LIGHTER_CANDLES_MAX_LIMIT),
1037                set_timestamp_to_end: Some(false),
1038            };
1039            let response = self.get_candles(&query).await?;
1040            let mut page = self.parse_bars(&response.candles, instrument, bar_type)?;
1041
1042            page.sort_by_key(|bar| bar.ts_event);
1043            for bar in page {
1044                let bar_start_ms = i64::try_from(bar.ts_event.as_u64() / 1_000_000)
1045                    .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1046                if bar_start_ms < cursor_ms
1047                    || bar_start_ms >= end_ms
1048                    || bar_start_ms.saturating_add(interval_ms) > now_ms
1049                {
1050                    continue;
1051                }
1052
1053                if bars
1054                    .last()
1055                    .is_some_and(|last: &Bar| last.ts_event == bar.ts_event)
1056                {
1057                    continue;
1058                }
1059                bars.push(bar);
1060
1061                if !start_was_unspecified
1062                    && let Some(limit) = requested_limit
1063                    && bars.len() >= limit
1064                {
1065                    break;
1066                }
1067            }
1068
1069            cursor_ms = window_end_ms;
1070            pages += 1;
1071        }
1072
1073        if pages >= MAX_BAR_REQUEST_PAGES {
1074            log::warn!("Stopped Lighter bar request after {MAX_BAR_REQUEST_PAGES} pages");
1075        }
1076
1077        if start_was_unspecified && bars.len() > target_limit {
1078            bars = bars.split_off(bars.len() - target_limit);
1079        }
1080
1081        Ok(bars)
1082    }
1083
1084    /// Requests historical funding rates and parses them into Nautilus updates.
1085    ///
1086    /// Lighter's public `/api/v1/fundings` endpoint returns settled funding
1087    /// rows at hourly or daily resolution. The adapter requests hourly rows and
1088    /// returns them in chronological order.
1089    ///
1090    /// # Errors
1091    ///
1092    /// Returns an error if the instrument is not a perpetual, the instrument
1093    /// has not been registered, the request range is invalid, or a row cannot
1094    /// be parsed.
1095    pub async fn request_funding_rates(
1096        &self,
1097        instrument: &InstrumentAny,
1098        start: Option<DateTime<Utc>>,
1099        end: Option<DateTime<Utc>>,
1100        limit: Option<usize>,
1101    ) -> LighterHttpResult<Vec<FundingRateUpdate>> {
1102        if !matches!(instrument, InstrumentAny::CryptoPerpetual(_)) {
1103            return Err(LighterHttpError::Parse(format!(
1104                "funding rates are only available for perpetual instruments: {}",
1105                instrument.id()
1106            )));
1107        }
1108
1109        let market_id = self.market_index(instrument)?;
1110        let resolution = LighterFundingResolution::OneHour;
1111        let interval_ms = resolution.interval_millis();
1112        let now = Utc::now();
1113
1114        if let (Some(start), Some(end)) = (start, end)
1115            && start >= end
1116        {
1117            return Err(LighterHttpError::Parse(format!(
1118                "invalid funding request range: start={start}, end={end}",
1119            )));
1120        }
1121
1122        let end = end.unwrap_or(now).min(now);
1123
1124        if let Some(start) = start
1125            && start >= end
1126        {
1127            return Ok(Vec::new());
1128        }
1129
1130        let requested_limit = limit.filter(|n| *n > 0);
1131        let target_limit = requested_limit.unwrap_or(DEFAULT_FUNDING_RATES_LIMIT);
1132        let start_was_unspecified = start.is_none();
1133        let end_ms = end.timestamp_millis().max(0);
1134
1135        if end_ms == 0 {
1136            return Ok(Vec::new());
1137        }
1138
1139        let start_ms = start.map_or_else(
1140            || {
1141                let lookback_rows = target_limit.saturating_add(1);
1142                let lookback_rows = i64::try_from(lookback_rows).unwrap_or(i64::MAX);
1143                let lookback_ms = interval_ms.saturating_mul(lookback_rows);
1144                end_ms.saturating_sub(lookback_ms)
1145            },
1146            |dt| dt.timestamp_millis().max(0),
1147        );
1148
1149        if start_ms >= end_ms {
1150            return Ok(Vec::new());
1151        }
1152
1153        let ts_init = self.generate_ts_init();
1154        let interval = Some(resolution.interval_minutes());
1155        let mut funding_rates = Vec::new();
1156        let mut cursor_ms = start_ms;
1157        let mut pages = 0_usize;
1158        // `cap - 1`, not `cap`: the endpoint excludes `end_timestamp`, so a
1159        // full-cap span with `count_back = cap` would drop the row at the cursor.
1160        let page_span_ms =
1161            interval_ms.saturating_mul(i64::from(LIGHTER_FUNDINGS_MAX_LIMIT.saturating_sub(1)));
1162
1163        while cursor_ms < end_ms && pages < MAX_FUNDING_REQUEST_PAGES {
1164            if !start_was_unspecified
1165                && let Some(limit) = requested_limit
1166                && funding_rates.len() >= limit
1167            {
1168                break;
1169            }
1170
1171            let window_end_ms = cursor_ms.saturating_add(page_span_ms).min(end_ms);
1172            if window_end_ms <= cursor_ms {
1173                break;
1174            }
1175
1176            let query = LighterFundingsQuery {
1177                market_id,
1178                resolution,
1179                start_timestamp: cursor_ms,
1180                end_timestamp: window_end_ms,
1181                count_back: i64::from(LIGHTER_FUNDINGS_MAX_LIMIT),
1182            };
1183            let response = self.get_fundings(&query).await?;
1184
1185            let mut page = Vec::with_capacity(response.fundings.len());
1186            for funding in &response.fundings {
1187                let update = parse_funding_rate_update(funding, instrument.id(), interval, ts_init)
1188                    .map_err(LighterHttpError::from)?;
1189                let timestamp_ms = i64::try_from(update.ts_event.as_u64() / 1_000_000)
1190                    .map_err(|e| LighterHttpError::Parse(e.to_string()))?;
1191                if timestamp_ms < cursor_ms || timestamp_ms > end_ms {
1192                    continue;
1193                }
1194                page.push(update);
1195            }
1196
1197            page.sort_by_key(|rate| rate.ts_event);
1198            for update in page {
1199                if funding_rates
1200                    .last()
1201                    .is_some_and(|last: &FundingRateUpdate| last.ts_event == update.ts_event)
1202                {
1203                    continue;
1204                }
1205                funding_rates.push(update);
1206
1207                if !start_was_unspecified
1208                    && let Some(limit) = requested_limit
1209                    && funding_rates.len() >= limit
1210                {
1211                    break;
1212                }
1213            }
1214
1215            cursor_ms = window_end_ms;
1216            pages += 1;
1217        }
1218
1219        if pages >= MAX_FUNDING_REQUEST_PAGES {
1220            log::warn!("Stopped Lighter funding request after {MAX_FUNDING_REQUEST_PAGES} pages");
1221        }
1222
1223        if start_was_unspecified && funding_rates.len() > target_limit {
1224            funding_rates = funding_rates.split_off(funding_rates.len() - target_limit);
1225        }
1226
1227        Ok(funding_rates)
1228    }
1229
1230    /// Requests all instruments from Lighter order book metadata.
1231    ///
1232    /// Lighter exposes market definitions through `GET /api/v1/orderBookDetails`.
1233    ///
1234    /// # Errors
1235    ///
1236    /// Returns an error if the request fails or an instrument cannot be parsed.
1237    pub async fn request_instruments(&self) -> LighterHttpResult<Vec<InstrumentAny>> {
1238        self.request_instruments_for_query(&LighterOrderBookDetailsQuery::default())
1239            .await
1240    }
1241
1242    /// Requests all instruments and their market statuses from Lighter order book metadata.
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns an error if the request fails or an instrument cannot be parsed.
1247    pub async fn request_instruments_with_status(
1248        &self,
1249    ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1250        self.request_instruments_with_status_for_query(&LighterOrderBookDetailsQuery::default())
1251            .await
1252    }
1253
1254    /// Requests a single instrument from Lighter order book metadata.
1255    ///
1256    /// # Errors
1257    ///
1258    /// Returns an error if the request fails, the instrument is not found, or the instrument
1259    /// cannot be parsed.
1260    pub async fn request_instrument(
1261        &self,
1262        instrument_id: InstrumentId,
1263    ) -> LighterHttpResult<InstrumentAny> {
1264        self.request_instrument_with_status(instrument_id)
1265            .await
1266            .map(|(instrument, _)| instrument)
1267    }
1268
1269    /// Requests a single instrument and its market status from Lighter order book metadata.
1270    ///
1271    /// # Errors
1272    ///
1273    /// Returns an error if the request fails, the instrument is not found, or the instrument
1274    /// cannot be parsed.
1275    pub async fn request_instrument_with_status(
1276        &self,
1277        instrument_id: InstrumentId,
1278    ) -> LighterHttpResult<(InstrumentAny, LighterMarketStatus)> {
1279        let query = LighterOrderBookDetailsQuery {
1280            market_id: self.market_registry.market_index(&instrument_id),
1281            filter: None,
1282        };
1283        let instruments = self
1284            .request_instruments_with_status_for_query(&query)
1285            .await?;
1286
1287        instruments
1288            .into_iter()
1289            .find(|(instrument, _)| instrument.id() == instrument_id)
1290            .ok_or_else(|| LighterHttpError::Parse(format!("instrument {instrument_id} not found")))
1291    }
1292
1293    /// Requests an HTTP order book snapshot and parses it into Nautilus order book deltas.
1294    ///
1295    /// # Errors
1296    ///
1297    /// Returns an error if the instrument has not been registered, the request fails, or any level
1298    /// cannot be parsed.
1299    pub async fn request_order_book_snapshot(
1300        &self,
1301        instrument: &InstrumentAny,
1302        limit: u16,
1303    ) -> LighterHttpResult<OrderBookDeltas> {
1304        let query = LighterOrderBookOrdersQuery {
1305            market_id: self.market_index(instrument)?,
1306            limit,
1307        };
1308        let snapshot = self.inner.get_order_book_orders(&query).await?;
1309        let ts_init = self.generate_ts_init();
1310
1311        parse_order_book_snapshot(
1312            &snapshot,
1313            instrument.id(),
1314            instrument.price_precision(),
1315            instrument.size_precision(),
1316            ts_init,
1317            ts_init,
1318        )
1319        .map_err(LighterHttpError::from)
1320    }
1321
1322    async fn request_instruments_for_query(
1323        &self,
1324        query: &LighterOrderBookDetailsQuery,
1325    ) -> LighterHttpResult<Vec<InstrumentAny>> {
1326        self.request_instruments_with_status_for_query(query)
1327            .await
1328            .map(|instruments| {
1329                instruments
1330                    .into_iter()
1331                    .map(|(instrument, _)| instrument)
1332                    .collect()
1333            })
1334    }
1335
1336    async fn request_instruments_with_status_for_query(
1337        &self,
1338        query: &LighterOrderBookDetailsQuery,
1339    ) -> LighterHttpResult<Vec<(InstrumentAny, LighterMarketStatus)>> {
1340        let response = self.get_order_book_details(query).await?;
1341        let ts_init = self.generate_ts_init();
1342        parse_order_book_details_instruments_with_status(
1343            &self.market_registry,
1344            &response.order_book_details,
1345            &response.spot_order_book_details,
1346            ts_init,
1347        )
1348        .map_err(LighterHttpError::from)
1349    }
1350
1351    fn parse_trade_ticks(
1352        &self,
1353        trades: &[LighterTrade],
1354        instrument: &InstrumentAny,
1355    ) -> LighterHttpResult<Vec<TradeTick>> {
1356        let ts_init = self.generate_ts_init();
1357        trades
1358            .iter()
1359            .map(|trade| parse_trade_tick(trade, instrument, ts_init).map_err(Into::into))
1360            .collect()
1361    }
1362
1363    fn parse_bars(
1364        &self,
1365        candles: &[LighterCandle],
1366        instrument: &InstrumentAny,
1367        bar_type: BarType,
1368    ) -> LighterHttpResult<Vec<Bar>> {
1369        let ts_init = self.generate_ts_init();
1370        candles
1371            .iter()
1372            .map(|candle| {
1373                parse_candle_bar(candle, bar_type, instrument, ts_init).map_err(Into::into)
1374            })
1375            .collect()
1376    }
1377
1378    fn market_index(&self, instrument: &InstrumentAny) -> LighterHttpResult<i16> {
1379        self.market_registry
1380            .market_index(&instrument.id())
1381            .ok_or_else(|| {
1382                LighterHttpError::Parse(format!(
1383                    "market index not registered for instrument {}",
1384                    instrument.id()
1385                ))
1386            })
1387    }
1388
1389    fn generate_ts_init(&self) -> UnixNanos {
1390        self.clock.get_time_ns()
1391    }
1392}
1393
1394fn venue_error(code: i32, message: Option<&str>, default_message: &str) -> LighterHttpError {
1395    LighterHttpError::Venue {
1396        code: i64::from(code),
1397        message: message.unwrap_or(default_message).to_string(),
1398    }
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use rstest::rstest;
1404
1405    use super::*;
1406
1407    #[rstest]
1408    #[case(ENDPOINT_TRADES, "lighter:trades")]
1409    #[case(ENDPOINT_SEND_TX, "lighter:sendTx")]
1410    #[case(ENDPOINT_SEND_TX_BATCH, "lighter:sendTxBatch")]
1411    fn test_rate_limit_keys_uses_rest_bucket_and_route(
1412        #[case] endpoint: &str,
1413        #[case] route: &str,
1414    ) {
1415        // Transactions are paced by the shared tx limiter, not a separate HTTP
1416        // key, so every endpoint (reads and tx) keys uniformly on the rest bucket.
1417        assert_eq!(
1418            LighterRawHttpClient::rate_limit_keys(endpoint),
1419            vec![LIGHTER_REST_BUCKET.to_string(), route.to_string()],
1420        );
1421    }
1422}