1use std::{collections::BTreeMap, str::FromStr};
19
20use nautilus_core::serialization::{
21 deserialize_decimal, deserialize_decimal_from_str, deserialize_decimal_or_zero,
22 deserialize_optional_decimal,
23};
24use rust_decimal::Decimal;
25use serde::{Deserialize, Serialize, de};
26use ustr::Ustr;
27use zeroize::ZeroizeOnDrop;
28
29use crate::common::enums::{
30 LighterCandleResolution, LighterFundingResolution, LighterMarketStatus, LighterOrderKind,
31 LighterOrderSide, LighterOrderStatus, LighterOrderTimeInForce, LighterPositionMarginMode,
32 LighterProductType, LighterTradeType, LighterTriggerStatus, LighterTxStatus,
33};
34
35#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
36pub struct LighterResultCode {
37 pub code: i32,
38 pub message: Option<String>,
39}
40
41#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
48pub struct LighterNextNonce {
49 pub code: i32,
50 pub message: Option<String>,
51 pub nonce: i64,
52}
53
54#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
56pub struct LighterTx {
57 pub code: i32,
58 pub message: Option<String>,
59 pub hash: String,
60 #[serde(rename = "type")]
61 pub tx_type: u8,
62 pub info: String,
63 pub event_info: String,
64 pub status: LighterTxStatus,
65 pub account_index: i64,
66 pub nonce: i64,
67 pub api_key_index: u8,
68}
69
70#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq, ZeroizeOnDrop)]
75pub struct LighterAccountDetail {
76 pub account_index: u64,
77 pub account_type: u8,
78 pub status: i32,
79 pub l1_address: String,
80}
81
82#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
84pub struct LighterAccountsResponse {
85 pub code: i32,
86 pub message: Option<String>,
87 pub total: i64,
88 #[serde(default, deserialize_with = "deserialize_null_vec")]
89 pub accounts: Vec<LighterAccountDetail>,
90}
91
92#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
101pub struct LighterMakerOnlyApiKeys {
102 pub code: i32,
103 pub message: Option<String>,
104 #[serde(default)]
105 pub api_key_indexes: Vec<i64>,
106}
107
108#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
109pub struct LighterSendTxRequest {
110 pub tx_type: u8,
111 pub tx_info: String,
112 #[serde(skip_serializing_if = "Option::is_none")]
113 pub price_protection: Option<bool>,
114}
115
116impl LighterSendTxRequest {
117 #[must_use]
118 pub fn new(tx_type: u8, tx_info: impl Into<String>) -> Self {
119 Self {
120 tx_type,
121 tx_info: tx_info.into(),
122 price_protection: None,
123 }
124 }
125
126 #[must_use]
127 pub const fn with_price_protection(mut self, price_protection: bool) -> Self {
128 self.price_protection = Some(price_protection);
129 self
130 }
131
132 pub(crate) fn form_fields(&self) -> Vec<(&'static str, String)> {
133 let mut fields = vec![
134 ("tx_type", self.tx_type.to_string()),
135 ("tx_info", self.tx_info.clone()),
136 ];
137
138 if let Some(price_protection) = self.price_protection {
139 fields.push(("price_protection", price_protection.to_string()));
140 }
141 fields
142 }
143}
144
145#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
146pub struct LighterSendTxBatchRequest {
147 pub tx_types: String,
148 pub tx_infos: String,
149}
150
151impl LighterSendTxBatchRequest {
152 #[must_use]
153 pub fn new(tx_types: impl Into<String>, tx_infos: impl Into<String>) -> Self {
154 Self {
155 tx_types: tx_types.into(),
156 tx_infos: tx_infos.into(),
157 }
158 }
159
160 pub(crate) fn form_fields(&self) -> Vec<(&'static str, String)> {
161 vec![
162 ("tx_types", self.tx_types.clone()),
163 ("tx_infos", self.tx_infos.clone()),
164 ]
165 }
166}
167
168#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
169pub struct LighterSendTxResponse {
170 pub code: i32,
171 pub message: Option<String>,
172 pub tx_hash: String,
173 pub predicted_execution_time_ms: i64,
174 pub volume_quota_remaining: Option<i64>,
175}
176
177#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
178pub struct LighterSendTxBatchResponse {
179 pub code: i32,
180 pub message: Option<String>,
181 #[serde(default, deserialize_with = "deserialize_null_vec")]
182 pub tx_hash: Vec<String>,
183 pub predicted_execution_time_ms: i64,
184 pub volume_quota_remaining: Option<i64>,
185}
186
187#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
188pub struct LighterOrderBooks {
189 pub code: i32,
190 pub message: Option<String>,
191 #[serde(default, deserialize_with = "deserialize_null_vec")]
192 pub order_books: Vec<LighterOrderBook>,
193}
194
195#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
196pub struct LighterOrderBook {
197 pub symbol: Ustr,
198 pub market_id: i64,
199 pub market_type: LighterProductType,
200 pub base_asset_id: i16,
201 pub quote_asset_id: i16,
202 pub status: LighterMarketStatus,
203 #[serde(deserialize_with = "deserialize_decimal_from_str")]
204 pub taker_fee: Decimal,
205 #[serde(deserialize_with = "deserialize_decimal_from_str")]
206 pub maker_fee: Decimal,
207 #[serde(deserialize_with = "deserialize_decimal_from_str")]
208 pub liquidation_fee: Decimal,
209 #[serde(deserialize_with = "deserialize_decimal_from_str")]
210 pub min_base_amount: Decimal,
211 #[serde(deserialize_with = "deserialize_decimal_from_str")]
212 pub min_quote_amount: Decimal,
213 #[serde(deserialize_with = "deserialize_decimal_from_str")]
214 pub order_quote_limit: Decimal,
215 pub supported_size_decimals: u8,
216 pub supported_price_decimals: u8,
217 pub supported_quote_decimals: u8,
218}
219
220#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
221pub struct LighterOrderBookDetails {
222 pub code: i32,
223 pub message: Option<String>,
224 #[serde(default, deserialize_with = "deserialize_null_vec")]
225 pub order_book_details: Vec<LighterPerpOrderBookDetail>,
226 #[serde(default, deserialize_with = "deserialize_null_vec")]
227 pub spot_order_book_details: Vec<LighterSpotOrderBookDetail>,
228}
229
230#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
231pub struct LighterPerpOrderBookDetail {
232 #[serde(flatten)]
233 pub order_book: LighterOrderBook,
234 pub size_decimals: u8,
235 pub price_decimals: u8,
236 pub quote_multiplier: i64,
237 pub default_initial_margin_fraction: u16,
238 pub min_initial_margin_fraction: u16,
239 pub maintenance_margin_fraction: u16,
240 pub closeout_margin_fraction: u16,
241 #[serde(deserialize_with = "deserialize_decimal")]
242 pub last_trade_price: Decimal,
243 pub daily_trades_count: i64,
244 #[serde(deserialize_with = "deserialize_decimal")]
245 pub daily_base_token_volume: Decimal,
246 #[serde(deserialize_with = "deserialize_decimal")]
247 pub daily_quote_token_volume: Decimal,
248 #[serde(deserialize_with = "deserialize_decimal")]
249 pub daily_price_low: Decimal,
250 #[serde(deserialize_with = "deserialize_decimal")]
251 pub daily_price_high: Decimal,
252 #[serde(deserialize_with = "deserialize_decimal")]
253 pub daily_price_change: Decimal,
254 #[serde(deserialize_with = "deserialize_decimal")]
255 pub open_interest: Decimal,
256 #[serde(deserialize_with = "deserialize_decimal_btree_map")]
257 pub daily_chart: BTreeMap<String, Decimal>,
258 pub market_config: LighterMarketConfig,
259 pub strategy_index: u8,
260}
261
262#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
263pub struct LighterSpotOrderBookDetail {
264 #[serde(flatten)]
265 pub order_book: LighterOrderBook,
266 pub size_decimals: u8,
267 pub price_decimals: u8,
268 #[serde(deserialize_with = "deserialize_decimal")]
269 pub last_trade_price: Decimal,
270 pub daily_trades_count: i64,
271 #[serde(deserialize_with = "deserialize_decimal")]
272 pub daily_base_token_volume: Decimal,
273 #[serde(deserialize_with = "deserialize_decimal")]
274 pub daily_quote_token_volume: Decimal,
275 #[serde(deserialize_with = "deserialize_decimal")]
276 pub daily_price_low: Decimal,
277 #[serde(deserialize_with = "deserialize_decimal")]
278 pub daily_price_high: Decimal,
279 #[serde(deserialize_with = "deserialize_decimal")]
280 pub daily_price_change: Decimal,
281 #[serde(deserialize_with = "deserialize_decimal_btree_map")]
282 pub daily_chart: BTreeMap<String, Decimal>,
283}
284
285#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
286pub struct LighterMarketConfig {
287 pub market_margin_mode: LighterPositionMarginMode,
288 pub insurance_fund_account_index: i64,
289 pub liquidation_mode: i32,
290 pub force_reduce_only: bool,
291 pub trading_hours: String,
292 pub funding_fee_discounts_enabled: bool,
293 pub hidden: bool,
294}
295
296#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
297pub struct LighterOrderBookOrders {
298 pub code: i32,
299 pub message: Option<String>,
300 pub total_asks: i64,
301 #[serde(default, deserialize_with = "deserialize_null_vec")]
302 pub asks: Vec<LighterSimpleOrder>,
303 pub total_bids: i64,
304 #[serde(default, deserialize_with = "deserialize_null_vec")]
305 pub bids: Vec<LighterSimpleOrder>,
306}
307
308#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
309pub struct LighterSimpleOrder {
310 pub order_index: i64,
311 pub order_id: String,
312 pub owner_account_index: i64,
313 #[serde(deserialize_with = "deserialize_decimal_from_str")]
314 pub initial_base_amount: Decimal,
315 #[serde(deserialize_with = "deserialize_decimal_from_str")]
316 pub remaining_base_amount: Decimal,
317 #[serde(deserialize_with = "deserialize_decimal_from_str")]
318 pub price: Decimal,
319 pub order_expiry: i64,
320 pub transaction_time: i64,
321}
322
323#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
324pub struct LighterOrderBookDepth {
325 pub code: i32,
326 pub message: Option<String>,
327 #[serde(default, deserialize_with = "deserialize_null_vec")]
328 pub asks: Vec<LighterPriceLevel>,
329 #[serde(default, deserialize_with = "deserialize_null_vec")]
330 pub bids: Vec<LighterPriceLevel>,
331 pub offset: i64,
332 pub nonce: i64,
333}
334
335#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
344pub struct LighterPriceLevel {
345 #[serde(deserialize_with = "deserialize_decimal_or_zero")]
346 pub price: Decimal,
347 #[serde(deserialize_with = "deserialize_decimal_or_zero")]
348 pub size: Decimal,
349}
350
351#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
352pub struct LighterTrades {
353 pub code: i32,
354 pub message: Option<String>,
355 pub next_cursor: Option<String>,
356 #[serde(default, deserialize_with = "deserialize_null_vec")]
357 pub trades: Vec<LighterTrade>,
358}
359
360#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
361pub struct LighterCandles {
362 pub code: i32,
363 pub message: Option<String>,
364 #[serde(rename = "r")]
365 pub resolution: LighterCandleResolution,
366 #[serde(rename = "c", default, deserialize_with = "deserialize_null_vec")]
367 pub candles: Vec<LighterCandle>,
368}
369
370#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
371pub struct LighterCandle {
372 #[serde(rename = "t")]
373 pub timestamp: i64,
374 #[serde(rename = "o", default, deserialize_with = "deserialize_decimal")]
375 pub open: Decimal,
376 #[serde(rename = "h", default, deserialize_with = "deserialize_decimal")]
377 pub high: Decimal,
378 #[serde(rename = "l", default, deserialize_with = "deserialize_decimal")]
379 pub low: Decimal,
380 #[serde(rename = "c", default, deserialize_with = "deserialize_decimal")]
381 pub close: Decimal,
382 #[serde(rename = "v", default, deserialize_with = "deserialize_decimal")]
383 pub volume_base: Decimal,
384 #[serde(rename = "V", default, deserialize_with = "deserialize_decimal")]
385 pub volume_quote: Decimal,
386 #[serde(rename = "i")]
387 pub last_trade_id: i64,
388}
389
390#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
391pub struct LighterFundings {
392 pub code: i32,
393 pub message: Option<String>,
394 pub resolution: LighterFundingResolution,
395 #[serde(default, deserialize_with = "deserialize_null_vec")]
396 pub fundings: Vec<LighterFunding>,
397}
398
399#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
400pub struct LighterFunding {
401 pub timestamp: i64,
402 #[serde(deserialize_with = "deserialize_decimal")]
403 pub value: Decimal,
404 #[serde(deserialize_with = "deserialize_decimal")]
405 pub rate: Decimal,
406 pub direction: LighterFundingDirection,
407}
408
409#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
410#[serde(rename_all = "lowercase")]
411pub enum LighterFundingDirection {
412 Long,
413 Short,
414}
415
416#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
417pub struct LighterTrade {
418 pub trade_id: i64,
419 pub trade_id_str: Option<String>,
420 pub tx_hash: String,
421 #[serde(rename = "type")]
422 pub trade_type: LighterTradeType,
423 pub market_id: i64,
424 #[serde(deserialize_with = "deserialize_decimal_from_str")]
425 pub size: Decimal,
426 #[serde(deserialize_with = "deserialize_decimal_from_str")]
427 pub price: Decimal,
428 #[serde(deserialize_with = "deserialize_decimal_from_str")]
429 pub usd_amount: Decimal,
430 pub ask_id: i64,
431 pub ask_id_str: Option<String>,
432 pub bid_id: i64,
433 pub bid_id_str: Option<String>,
434 pub ask_client_id: i64,
435 pub ask_client_id_str: Option<String>,
436 pub bid_client_id: i64,
437 pub bid_client_id_str: Option<String>,
438 pub ask_account_id: i64,
439 pub bid_account_id: i64,
440 pub is_maker_ask: bool,
441 pub block_height: i64,
442 pub timestamp: i64,
443 pub taker_fee: Option<i32>,
444 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
445 pub taker_position_size_before: Option<Decimal>,
446 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
447 pub taker_entry_quote_before: Option<Decimal>,
448 pub taker_initial_margin_fraction_before: Option<u16>,
449 pub taker_position_sign_changed: Option<bool>,
450 pub maker_fee: Option<i32>,
451 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
452 pub maker_position_size_before: Option<Decimal>,
453 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
454 pub maker_entry_quote_before: Option<Decimal>,
455 pub maker_initial_margin_fraction_before: Option<u16>,
456 pub maker_position_sign_changed: Option<bool>,
457 pub transaction_time: i64,
458 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
459 pub ask_account_pnl: Option<Decimal>,
460 #[serde(default, deserialize_with = "deserialize_optional_decimal")]
461 pub bid_account_pnl: Option<Decimal>,
462}
463
464#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
465pub struct LighterOrders {
466 pub code: i32,
467 pub message: Option<String>,
468 pub next_cursor: Option<String>,
469 #[serde(default, deserialize_with = "deserialize_null_vec")]
470 pub orders: Vec<LighterOrder>,
471}
472
473#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
474pub struct LighterOrder {
475 pub order_index: i64,
476 pub client_order_index: i64,
477 pub order_id: String,
478 pub client_order_id: String,
479 pub market_index: i64,
480 pub owner_account_index: i64,
481 #[serde(deserialize_with = "deserialize_decimal_from_str")]
482 pub initial_base_amount: Decimal,
483 #[serde(deserialize_with = "deserialize_decimal_from_str")]
484 pub price: Decimal,
485 pub nonce: i64,
486 #[serde(deserialize_with = "deserialize_decimal_from_str")]
487 pub remaining_base_amount: Decimal,
488 pub is_ask: bool,
489 pub base_size: i64,
490 pub base_price: i32,
491 #[serde(deserialize_with = "deserialize_decimal_from_str")]
492 pub filled_base_amount: Decimal,
493 #[serde(deserialize_with = "deserialize_decimal_from_str")]
494 pub filled_quote_amount: Decimal,
495 #[serde(default, deserialize_with = "deserialize_order_side")]
496 pub side: Option<LighterOrderSide>,
497 #[serde(rename = "type")]
498 pub order_type: LighterOrderKind,
499 pub time_in_force: LighterOrderTimeInForce,
500 pub reduce_only: bool,
501 #[serde(deserialize_with = "deserialize_decimal_or_zero")]
503 pub trigger_price: Decimal,
504 pub order_expiry: i64,
505 pub status: LighterOrderStatus,
506 pub trigger_status: LighterTriggerStatus,
507 pub trigger_time: i64,
508 pub parent_order_index: i64,
509 pub parent_order_id: String,
510 pub to_trigger_order_id_0: String,
511 pub to_trigger_order_id_1: String,
512 pub to_cancel_order_id_0: String,
513 pub integrator_fee_collector_index: String,
514 #[serde(deserialize_with = "deserialize_decimal_or_zero")]
515 pub integrator_taker_fee: Decimal,
516 #[serde(deserialize_with = "deserialize_decimal_or_zero")]
517 pub integrator_maker_fee: Decimal,
518 pub block_height: i64,
519 pub timestamp: i64,
520 pub created_at: i64,
521 pub updated_at: i64,
522 pub transaction_time: i64,
523}
524
525#[derive(Deserialize)]
526struct DecimalFromAny(#[serde(deserialize_with = "deserialize_decimal")] Decimal);
527
528fn deserialize_decimal_btree_map<'de, D>(
529 deserializer: D,
530) -> Result<BTreeMap<String, Decimal>, D::Error>
531where
532 D: serde::Deserializer<'de>,
533{
534 BTreeMap::<String, DecimalFromAny>::deserialize(deserializer).map(|values| {
535 values
536 .into_iter()
537 .map(|(key, decimal)| (key, decimal.0))
538 .collect()
539 })
540}
541
542fn deserialize_null_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
543where
544 D: serde::Deserializer<'de>,
545 T: Deserialize<'de>,
546{
547 Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
548}
549
550fn deserialize_order_side<'de, D>(deserializer: D) -> Result<Option<LighterOrderSide>, D::Error>
551where
552 D: serde::Deserializer<'de>,
553{
554 let value = Option::<String>::deserialize(deserializer)?;
555 match value.as_deref() {
556 None | Some("") => Ok(None),
557 Some(s) => LighterOrderSide::from_str(s)
558 .map(Some)
559 .map_err(|_| de::Error::unknown_variant(s, &["buy", "sell", ""])),
560 }
561}
562
563#[cfg(test)]
564mod tests {
565 use rstest::rstest;
566
567 use super::*;
568
569 const HTTP_ORDER_BOOK_DETAILS: &str =
570 include_str!("../../test_data/http_order_book_details.json");
571 const HTTP_ORDER_BOOK_DETAILS_WIDENED_IDS: &str =
572 include_str!("../../test_data/http_order_book_details_widened_ids.json");
573 const HTTP_RECENT_TRADES: &str = include_str!("../../test_data/http_recent_trades.json");
574 const HTTP_RECENT_TRADES_MISSING: &str =
575 include_str!("../../test_data/http_recent_trades_missing.json");
576 const HTTP_RECENT_TRADES_NULL: &str =
577 include_str!("../../test_data/http_recent_trades_null.json");
578 const HTTP_ORDER_BOOKS: &str = include_str!("../../test_data/http_order_books.json");
579 const HTTP_ORDER_BOOKS_WIDENED_IDS: &str =
580 include_str!("../../test_data/http_order_books_widened_ids.json");
581 const HTTP_ORDER_BOOK_ORDERS: &str =
582 include_str!("../../test_data/http_order_book_orders.json");
583 const HTTP_ORDER_BOOK_DEPTH: &str = include_str!("../../test_data/http_order_book_depth.json");
584 const HTTP_ORDER_BOOK_DEPTH_NULL: &str =
585 include_str!("../../test_data/http_order_book_depth_null.json");
586 const HTTP_ORDERS: &str = include_str!("../../test_data/http_orders.json");
587 const HTTP_CANDLES: &str = include_str!("../../test_data/http_candles.json");
588 const HTTP_CANDLES_NULL: &str = include_str!("../../test_data/http_candles_null.json");
589 const HTTP_FUNDINGS: &str = include_str!("../../test_data/http_fundings.json");
590 const HTTP_ACCOUNT: &str = include_str!("../../test_data/http_account.json");
591
592 #[rstest]
593 fn test_account_response_deserializes_live_shape() {
594 let response: LighterAccountsResponse = serde_json::from_str(HTTP_ACCOUNT).unwrap();
595
596 assert_eq!(response.code, 200);
597 assert_eq!(response.total, 1);
598 assert_eq!(response.accounts.len(), 1);
599 let account = &response.accounts[0];
600 assert_eq!(account.account_index, 123_456);
601 assert_eq!(account.account_type, 0);
602 assert_eq!(account.status, 1);
603 assert_eq!(
604 account.l1_address,
605 "0x0000000000000000000000000000000000000000"
606 );
607 }
608
609 #[rstest]
610 fn test_account_response_allows_missing_or_null_accounts() {
611 let missing = serde_json::json!({"code": 200, "total": 0});
612 let null = serde_json::json!({"code": 200, "total": 0, "accounts": null});
613
614 let missing: LighterAccountsResponse = serde_json::from_value(missing).unwrap();
615 let null: LighterAccountsResponse = serde_json::from_value(null).unwrap();
616
617 assert!(missing.accounts.is_empty());
618 assert!(null.accounts.is_empty());
619 }
620
621 #[rstest]
622 fn test_send_tx_batch_response_allows_missing_or_null_tx_hash() {
623 let missing = serde_json::json!({
624 "code": 200,
625 "predicted_execution_time_ms": 1_751_465_475,
626 });
627 let null = serde_json::json!({
628 "code": 200,
629 "tx_hash": null,
630 "predicted_execution_time_ms": 1_751_465_475,
631 });
632
633 let missing: LighterSendTxBatchResponse = serde_json::from_value(missing).unwrap();
634 let null: LighterSendTxBatchResponse = serde_json::from_value(null).unwrap();
635
636 assert!(missing.tx_hash.is_empty());
637 assert!(null.tx_hash.is_empty());
638 }
639
640 #[rstest]
641 fn test_order_book_details_deserializes_live_shape() {
642 let details: LighterOrderBookDetails =
643 serde_json::from_str(HTTP_ORDER_BOOK_DETAILS).unwrap();
644
645 assert_eq!(details.code, 200);
646 assert_eq!(details.order_book_details.len(), 1);
647 assert_eq!(
648 details.order_book_details[0].order_book.market_type,
649 LighterProductType::Perp,
650 );
651 assert_eq!(details.order_book_details[0].price_decimals, 2);
652 assert_eq!(
653 details.order_book_details[0].last_trade_price,
654 Decimal::new(236_131, 2),
655 );
656 assert_eq!(
657 details.order_book_details[0].daily_base_token_volume,
658 Decimal::new(1_953_991_521, 4),
659 );
660 assert_eq!(
661 details.order_book_details[0]
662 .market_config
663 .market_margin_mode,
664 LighterPositionMarginMode::Cross,
665 );
666 assert!(details.spot_order_book_details.is_empty());
667 }
668
669 #[rstest]
670 fn test_order_book_details_deserializes_widened_market_ids() {
671 let details: LighterOrderBookDetails =
672 serde_json::from_str(HTTP_ORDER_BOOK_DETAILS_WIDENED_IDS).unwrap();
673
674 assert_eq!(details.code, 200);
675 assert_eq!(details.order_book_details.len(), 2);
676 assert_eq!(details.spot_order_book_details.len(), 2);
677
678 let perp_ids: Vec<i64> = details
679 .order_book_details
680 .iter()
681 .map(|d| d.order_book.market_id)
682 .collect();
683 assert_eq!(perp_ids, vec![4095, 40_000]);
684 assert!(
685 details
686 .order_book_details
687 .iter()
688 .all(|d| d.order_book.market_type == LighterProductType::Perp)
689 );
690
691 let spot_ids: Vec<i64> = details
692 .spot_order_book_details
693 .iter()
694 .map(|d| d.order_book.market_id)
695 .collect();
696 assert_eq!(spot_ids, vec![4098, 50_000]);
697 assert!(
698 details
699 .spot_order_book_details
700 .iter()
701 .all(|d| d.order_book.market_type == LighterProductType::Spot)
702 );
703 }
704
705 #[rstest]
706 fn test_recent_trades_allow_omitted_fee_fields() {
707 let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES).unwrap();
708
709 assert_eq!(trades.trades.len(), 1);
710 assert_eq!(
711 trades.trades[0].trade_id_str.as_deref(),
712 Some("19211490282")
713 );
714 assert_eq!(trades.trades[0].taker_fee, None);
715 assert_eq!(trades.trades[0].maker_fee, Some(28));
716 }
717
718 #[rstest]
719 fn test_recent_trades_deserializes_null_trades_as_empty() {
720 let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES_NULL).unwrap();
721
722 assert_eq!(trades.code, 200);
723 assert!(trades.trades.is_empty());
724 }
725
726 #[rstest]
727 fn test_recent_trades_deserializes_missing_trades_as_empty() {
728 let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES_MISSING).unwrap();
729
730 assert_eq!(trades.code, 200);
731 assert!(trades.trades.is_empty());
732 }
733
734 #[rstest]
735 fn test_candles_deserializes_live_shape_with_omitted_raw_fields() {
736 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
737
738 assert_eq!(candles.code, 200);
739 assert_eq!(candles.resolution, LighterCandleResolution::OneMinute);
740 assert_eq!(candles.candles.len(), 2);
741 assert_eq!(candles.candles[0].timestamp, 1_700_000_000_000);
742 assert_eq!(candles.candles[0].open, Decimal::new(236_111, 2));
743 assert_eq!(candles.candles[0].high, Decimal::new(236_222, 2));
744 assert_eq!(candles.candles[0].low, Decimal::new(236_000, 2));
745 assert_eq!(candles.candles[0].close, Decimal::new(236_131, 2));
746 assert_eq!(candles.candles[0].volume_base, Decimal::new(12_345, 4));
747 assert_eq!(candles.candles[0].last_trade_id, 19_211_490_282);
748 }
749
750 #[rstest]
751 fn test_candles_deserializes_null_candles_as_empty() {
752 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES_NULL).unwrap();
753
754 assert_eq!(candles.code, 200);
755 assert_eq!(candles.resolution, LighterCandleResolution::OneMinute);
756 assert!(candles.candles.is_empty());
757 }
758
759 #[rstest]
760 #[case("o")]
761 #[case("h")]
762 #[case("l")]
763 #[case("c")]
764 fn test_candle_missing_or_null_ohlc_deserializes_as_zero(#[case] field: &str) {
765 let base = serde_json::json!({
766 "t": 1_700_000_000_000_i64,
767 "o": "1",
768 "h": "1",
769 "l": "1",
770 "c": "1",
771 "v": "1",
772 "V": "1",
773 "i": 1,
774 });
775 let mut missing = base.clone();
776 missing.as_object_mut().unwrap().remove(field);
777 let mut null = base;
778 null[field] = serde_json::Value::Null;
779
780 let missing: LighterCandle = serde_json::from_value(missing).unwrap();
781 let null: LighterCandle = serde_json::from_value(null).unwrap();
782 let value_for = |candle: &LighterCandle| match field {
783 "o" => candle.open,
784 "h" => candle.high,
785 "l" => candle.low,
786 "c" => candle.close,
787 _ => unreachable!(),
788 };
789
790 assert_eq!(value_for(&missing), Decimal::ZERO);
791 assert_eq!(value_for(&null), Decimal::ZERO);
792 }
793
794 #[rstest]
795 fn test_fundings_deserializes_live_shape() {
796 let fundings: LighterFundings = serde_json::from_str(HTTP_FUNDINGS).unwrap();
797
798 assert_eq!(fundings.code, 200);
799 assert_eq!(fundings.resolution, LighterFundingResolution::OneHour);
800 assert_eq!(fundings.fundings.len(), 2);
801 assert_eq!(fundings.fundings[0].timestamp, 1_778_702_400);
802 assert_eq!(fundings.fundings[0].rate, Decimal::new(12, 4));
803 assert_eq!(
804 fundings.fundings[0].direction,
805 LighterFundingDirection::Long
806 );
807 assert_eq!(
808 fundings.fundings[1].direction,
809 LighterFundingDirection::Short
810 );
811 }
812
813 #[rstest]
814 fn test_order_books_deserializes_live_shape() {
815 let order_books: LighterOrderBooks = serde_json::from_str(HTTP_ORDER_BOOKS).unwrap();
816
817 assert_eq!(order_books.code, 200);
818 assert_eq!(order_books.order_books.len(), 1);
819 assert_eq!(order_books.order_books[0].symbol, Ustr::from("ETH"));
820 assert_eq!(
821 order_books.order_books[0].market_type,
822 LighterProductType::Perp
823 );
824 assert_eq!(
825 order_books.order_books[0].status,
826 LighterMarketStatus::Active
827 );
828 assert_eq!(order_books.order_books[0].supported_price_decimals, 2);
829 }
830
831 #[rstest]
832 fn test_order_books_deserializes_widened_market_ids() {
833 let order_books: LighterOrderBooks =
834 serde_json::from_str(HTTP_ORDER_BOOKS_WIDENED_IDS).unwrap();
835
836 assert_eq!(order_books.code, 200);
837 assert_eq!(order_books.order_books.len(), 2);
838 assert_eq!(order_books.order_books[0].market_id, 4095);
839 assert_eq!(
840 order_books.order_books[0].market_type,
841 LighterProductType::Perp
842 );
843 assert_eq!(order_books.order_books[1].market_id, 50_000);
844 assert_eq!(
845 order_books.order_books[1].market_type,
846 LighterProductType::Spot
847 );
848 }
849
850 #[rstest]
851 fn test_order_book_orders_deserializes_live_shape() {
852 let book: LighterOrderBookOrders = serde_json::from_str(HTTP_ORDER_BOOK_ORDERS).unwrap();
853
854 assert_eq!(book.total_asks, 1);
855 assert_eq!(book.asks[0].order_id, "281476929689581");
856 assert_eq!(book.asks[0].price, Decimal::from_str("2361.32").unwrap());
857 assert_eq!(book.total_bids, 1);
858 assert_eq!(
859 book.bids[0].remaining_base_amount,
860 Decimal::from_str("3.4125").unwrap(),
861 );
862 }
863
864 #[rstest]
865 fn test_order_book_depth_deserializes_live_shape() {
866 let depth: LighterOrderBookDepth = serde_json::from_str(HTTP_ORDER_BOOK_DEPTH).unwrap();
867
868 assert_eq!(depth.code, 200);
869 assert_eq!(depth.asks[0].price, Decimal::from_str("2352.74").unwrap());
870 assert_eq!(depth.bids[0].size, Decimal::from_str("0.2125").unwrap());
871 assert_eq!(depth.offset, 1_558_300);
872 assert_eq!(depth.nonce, 9_182_390_020);
873 }
874
875 #[rstest]
876 fn test_order_book_depth_deserializes_null_sides_as_empty() {
877 let depth: LighterOrderBookDepth =
878 serde_json::from_str(HTTP_ORDER_BOOK_DEPTH_NULL).unwrap();
879
880 assert_eq!(depth.code, 200);
881 assert!(depth.asks.is_empty());
882 assert!(depth.bids.is_empty());
883 assert_eq!(depth.offset, 1);
884 assert_eq!(depth.nonce, 0);
885 }
886
887 #[rstest]
888 fn test_orders_deserializes_live_shape() {
889 let orders: LighterOrders = serde_json::from_str(HTTP_ORDERS).unwrap();
890
891 assert_eq!(orders.next_cursor.as_deref(), Some("cursor-1"));
892 assert_eq!(orders.orders.len(), 1);
893 assert_eq!(orders.orders[0].order_type, LighterOrderKind::Limit);
894 assert_eq!(
895 orders.orders[0].time_in_force,
896 LighterOrderTimeInForce::GoodTillTime,
897 );
898 assert_eq!(orders.orders[0].status, LighterOrderStatus::Open);
899 assert_eq!(orders.orders[0].trigger_status, LighterTriggerStatus::Na);
900 assert_eq!(orders.orders[0].side, Some(LighterOrderSide::Sell));
901 assert!(orders.orders[0].is_ask);
902 }
903
904 #[rstest]
905 fn test_orders_allows_empty_side_with_is_ask() {
906 let mut value: serde_json::Value = serde_json::from_str(HTTP_ORDERS).unwrap();
907 value["orders"][0]["side"] = serde_json::Value::String(String::new());
908
909 let orders: LighterOrders = serde_json::from_value(value).unwrap();
910
911 assert_eq!(orders.orders[0].side, None);
912 assert!(orders.orders[0].is_ask);
913 }
914}