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