1use 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
94pub const LIGHTER_REST_PAGE_SIZE: u16 = 100;
99pub const LIGHTER_CANDLES_MAX_LIMIT: u16 = 500;
100
101pub 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#[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 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 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 #[must_use]
232 pub fn base_url(&self) -> &str {
233 self.base_url.as_str()
234 }
235
236 #[must_use]
238 pub const fn environment(&self) -> LighterEnvironment {
239 self.environment
240 }
241
242 pub fn set_base_url(&mut self, base_url: &str) {
244 self.base_url = base_url.trim_end_matches('/').to_string();
245 }
246
247 pub fn set_retry_manager(&mut self, retry_manager: RetryManager<LighterHttpError>) {
250 self.retry_manager = retry_manager;
251 }
252
253 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 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 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 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 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
311 self.send_get_request(ENDPOINT_TRADES, Some(query)).await
312 }
313
314 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 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 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 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 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 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 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(¶ms), headers)
407 .await
408 }
409
410 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 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 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 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 if status >= 500 {
541 return Err(LighterHttpError::Http { status, body });
542 }
543
544 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#[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 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 #[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 #[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 #[must_use]
693 pub fn base_url(&self) -> &str {
694 self.inner.base_url()
695 }
696
697 #[must_use]
699 pub fn environment(&self) -> LighterEnvironment {
700 self.inner.environment()
701 }
702
703 #[must_use]
705 pub fn market_registry(&self) -> Arc<MarketRegistry> {
706 self.market_registry.clone()
707 }
708
709 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 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 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 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 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 pub async fn get_trades(&self, query: &LighterTradesQuery) -> LighterHttpResult<LighterTrades> {
779 self.inner.get_trades(query).await
780 }
781
782 pub async fn get_candles(
788 &self,
789 query: &LighterCandlesQuery,
790 ) -> LighterHttpResult<LighterCandles> {
791 self.inner.get_candles(query).await
792 }
793
794 pub async fn get_fundings(
800 &self,
801 query: &LighterFundingsQuery,
802 ) -> LighterHttpResult<LighterFundings> {
803 self.inner.get_fundings(query).await
804 }
805
806 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 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 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 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 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 pub async fn send_tx(
899 &self,
900 request: &LighterSendTxRequest,
901 ) -> LighterHttpResult<LighterSendTxResponse> {
902 self.inner.send_tx(request).await
903 }
904
905 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 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 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 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 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 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 pub async fn request_instruments(&self) -> LighterHttpResult<Vec<InstrumentAny>> {
1238 self.request_instruments_for_query(&LighterOrderBookDetailsQuery::default())
1239 .await
1240 }
1241
1242 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 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 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 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 assert_eq!(
1418 LighterRawHttpClient::rate_limit_keys(endpoint),
1419 vec![LIGHTER_REST_BUCKET.to_string(), route.to_string()],
1420 );
1421 }
1422}