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: i16,
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: i16,
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: i16,
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_RECENT_TRADES: &str = include_str!("../../test_data/http_recent_trades.json");
572 const HTTP_RECENT_TRADES_MISSING: &str =
573 include_str!("../../test_data/http_recent_trades_missing.json");
574 const HTTP_RECENT_TRADES_NULL: &str =
575 include_str!("../../test_data/http_recent_trades_null.json");
576 const HTTP_ORDER_BOOKS: &str = include_str!("../../test_data/http_order_books.json");
577 const HTTP_ORDER_BOOK_ORDERS: &str =
578 include_str!("../../test_data/http_order_book_orders.json");
579 const HTTP_ORDER_BOOK_DEPTH: &str = include_str!("../../test_data/http_order_book_depth.json");
580 const HTTP_ORDER_BOOK_DEPTH_NULL: &str =
581 include_str!("../../test_data/http_order_book_depth_null.json");
582 const HTTP_ORDERS: &str = include_str!("../../test_data/http_orders.json");
583 const HTTP_CANDLES: &str = include_str!("../../test_data/http_candles.json");
584 const HTTP_CANDLES_NULL: &str = include_str!("../../test_data/http_candles_null.json");
585 const HTTP_FUNDINGS: &str = include_str!("../../test_data/http_fundings.json");
586 const HTTP_ACCOUNT: &str = include_str!("../../test_data/http_account.json");
587
588 #[rstest]
589 fn test_account_response_deserializes_live_shape() {
590 let response: LighterAccountsResponse = serde_json::from_str(HTTP_ACCOUNT).unwrap();
591
592 assert_eq!(response.code, 200);
593 assert_eq!(response.total, 1);
594 assert_eq!(response.accounts.len(), 1);
595 let account = &response.accounts[0];
596 assert_eq!(account.account_index, 123_456);
597 assert_eq!(account.account_type, 0);
598 assert_eq!(account.status, 1);
599 assert_eq!(
600 account.l1_address,
601 "0x0000000000000000000000000000000000000000"
602 );
603 }
604
605 #[rstest]
606 fn test_account_response_allows_missing_or_null_accounts() {
607 let missing = serde_json::json!({"code": 200, "total": 0});
608 let null = serde_json::json!({"code": 200, "total": 0, "accounts": null});
609
610 let missing: LighterAccountsResponse = serde_json::from_value(missing).unwrap();
611 let null: LighterAccountsResponse = serde_json::from_value(null).unwrap();
612
613 assert!(missing.accounts.is_empty());
614 assert!(null.accounts.is_empty());
615 }
616
617 #[rstest]
618 fn test_send_tx_batch_response_allows_missing_or_null_tx_hash() {
619 let missing = serde_json::json!({
620 "code": 200,
621 "predicted_execution_time_ms": 1_751_465_475,
622 });
623 let null = serde_json::json!({
624 "code": 200,
625 "tx_hash": null,
626 "predicted_execution_time_ms": 1_751_465_475,
627 });
628
629 let missing: LighterSendTxBatchResponse = serde_json::from_value(missing).unwrap();
630 let null: LighterSendTxBatchResponse = serde_json::from_value(null).unwrap();
631
632 assert!(missing.tx_hash.is_empty());
633 assert!(null.tx_hash.is_empty());
634 }
635
636 #[rstest]
637 fn test_order_book_details_deserializes_live_shape() {
638 let details: LighterOrderBookDetails =
639 serde_json::from_str(HTTP_ORDER_BOOK_DETAILS).unwrap();
640
641 assert_eq!(details.code, 200);
642 assert_eq!(details.order_book_details.len(), 1);
643 assert_eq!(
644 details.order_book_details[0].order_book.market_type,
645 LighterProductType::Perp,
646 );
647 assert_eq!(details.order_book_details[0].price_decimals, 2);
648 assert_eq!(
649 details.order_book_details[0].last_trade_price,
650 Decimal::new(236_131, 2),
651 );
652 assert_eq!(
653 details.order_book_details[0].daily_base_token_volume,
654 Decimal::new(1_953_991_521, 4),
655 );
656 assert_eq!(
657 details.order_book_details[0]
658 .market_config
659 .market_margin_mode,
660 LighterPositionMarginMode::Cross,
661 );
662 assert!(details.spot_order_book_details.is_empty());
663 }
664
665 #[rstest]
666 fn test_recent_trades_allow_omitted_fee_fields() {
667 let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES).unwrap();
668
669 assert_eq!(trades.trades.len(), 1);
670 assert_eq!(
671 trades.trades[0].trade_id_str.as_deref(),
672 Some("19211490282")
673 );
674 assert_eq!(trades.trades[0].taker_fee, None);
675 assert_eq!(trades.trades[0].maker_fee, Some(28));
676 }
677
678 #[rstest]
679 fn test_recent_trades_deserializes_null_trades_as_empty() {
680 let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES_NULL).unwrap();
681
682 assert_eq!(trades.code, 200);
683 assert!(trades.trades.is_empty());
684 }
685
686 #[rstest]
687 fn test_recent_trades_deserializes_missing_trades_as_empty() {
688 let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES_MISSING).unwrap();
689
690 assert_eq!(trades.code, 200);
691 assert!(trades.trades.is_empty());
692 }
693
694 #[rstest]
695 fn test_candles_deserializes_live_shape_with_omitted_raw_fields() {
696 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
697
698 assert_eq!(candles.code, 200);
699 assert_eq!(candles.resolution, LighterCandleResolution::OneMinute);
700 assert_eq!(candles.candles.len(), 2);
701 assert_eq!(candles.candles[0].timestamp, 1_700_000_000_000);
702 assert_eq!(candles.candles[0].open, Decimal::new(236_111, 2));
703 assert_eq!(candles.candles[0].high, Decimal::new(236_222, 2));
704 assert_eq!(candles.candles[0].low, Decimal::new(236_000, 2));
705 assert_eq!(candles.candles[0].close, Decimal::new(236_131, 2));
706 assert_eq!(candles.candles[0].volume_base, Decimal::new(12_345, 4));
707 assert_eq!(candles.candles[0].last_trade_id, 19_211_490_282);
708 }
709
710 #[rstest]
711 fn test_candles_deserializes_null_candles_as_empty() {
712 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES_NULL).unwrap();
713
714 assert_eq!(candles.code, 200);
715 assert_eq!(candles.resolution, LighterCandleResolution::OneMinute);
716 assert!(candles.candles.is_empty());
717 }
718
719 #[rstest]
720 #[case("o")]
721 #[case("h")]
722 #[case("l")]
723 #[case("c")]
724 fn test_candle_missing_or_null_ohlc_deserializes_as_zero(#[case] field: &str) {
725 let base = serde_json::json!({
726 "t": 1_700_000_000_000_i64,
727 "o": "1",
728 "h": "1",
729 "l": "1",
730 "c": "1",
731 "v": "1",
732 "V": "1",
733 "i": 1,
734 });
735 let mut missing = base.clone();
736 missing.as_object_mut().unwrap().remove(field);
737 let mut null = base;
738 null[field] = serde_json::Value::Null;
739
740 let missing: LighterCandle = serde_json::from_value(missing).unwrap();
741 let null: LighterCandle = serde_json::from_value(null).unwrap();
742 let value_for = |candle: &LighterCandle| match field {
743 "o" => candle.open,
744 "h" => candle.high,
745 "l" => candle.low,
746 "c" => candle.close,
747 _ => unreachable!(),
748 };
749
750 assert_eq!(value_for(&missing), Decimal::ZERO);
751 assert_eq!(value_for(&null), Decimal::ZERO);
752 }
753
754 #[rstest]
755 fn test_fundings_deserializes_live_shape() {
756 let fundings: LighterFundings = serde_json::from_str(HTTP_FUNDINGS).unwrap();
757
758 assert_eq!(fundings.code, 200);
759 assert_eq!(fundings.resolution, LighterFundingResolution::OneHour);
760 assert_eq!(fundings.fundings.len(), 2);
761 assert_eq!(fundings.fundings[0].timestamp, 1_778_702_400);
762 assert_eq!(fundings.fundings[0].rate, Decimal::new(12, 4));
763 assert_eq!(
764 fundings.fundings[0].direction,
765 LighterFundingDirection::Long
766 );
767 assert_eq!(
768 fundings.fundings[1].direction,
769 LighterFundingDirection::Short
770 );
771 }
772
773 #[rstest]
774 fn test_order_books_deserializes_live_shape() {
775 let order_books: LighterOrderBooks = serde_json::from_str(HTTP_ORDER_BOOKS).unwrap();
776
777 assert_eq!(order_books.code, 200);
778 assert_eq!(order_books.order_books.len(), 1);
779 assert_eq!(order_books.order_books[0].symbol, Ustr::from("ETH"));
780 assert_eq!(
781 order_books.order_books[0].market_type,
782 LighterProductType::Perp
783 );
784 assert_eq!(
785 order_books.order_books[0].status,
786 LighterMarketStatus::Active
787 );
788 assert_eq!(order_books.order_books[0].supported_price_decimals, 2);
789 }
790
791 #[rstest]
792 fn test_order_book_orders_deserializes_live_shape() {
793 let book: LighterOrderBookOrders = serde_json::from_str(HTTP_ORDER_BOOK_ORDERS).unwrap();
794
795 assert_eq!(book.total_asks, 1);
796 assert_eq!(book.asks[0].order_id, "281476929689581");
797 assert_eq!(book.asks[0].price, Decimal::from_str("2361.32").unwrap());
798 assert_eq!(book.total_bids, 1);
799 assert_eq!(
800 book.bids[0].remaining_base_amount,
801 Decimal::from_str("3.4125").unwrap(),
802 );
803 }
804
805 #[rstest]
806 fn test_order_book_depth_deserializes_live_shape() {
807 let depth: LighterOrderBookDepth = serde_json::from_str(HTTP_ORDER_BOOK_DEPTH).unwrap();
808
809 assert_eq!(depth.code, 200);
810 assert_eq!(depth.asks[0].price, Decimal::from_str("2352.74").unwrap());
811 assert_eq!(depth.bids[0].size, Decimal::from_str("0.2125").unwrap());
812 assert_eq!(depth.offset, 1_558_300);
813 assert_eq!(depth.nonce, 9_182_390_020);
814 }
815
816 #[rstest]
817 fn test_order_book_depth_deserializes_null_sides_as_empty() {
818 let depth: LighterOrderBookDepth =
819 serde_json::from_str(HTTP_ORDER_BOOK_DEPTH_NULL).unwrap();
820
821 assert_eq!(depth.code, 200);
822 assert!(depth.asks.is_empty());
823 assert!(depth.bids.is_empty());
824 assert_eq!(depth.offset, 1);
825 assert_eq!(depth.nonce, 0);
826 }
827
828 #[rstest]
829 fn test_orders_deserializes_live_shape() {
830 let orders: LighterOrders = serde_json::from_str(HTTP_ORDERS).unwrap();
831
832 assert_eq!(orders.next_cursor.as_deref(), Some("cursor-1"));
833 assert_eq!(orders.orders.len(), 1);
834 assert_eq!(orders.orders[0].order_type, LighterOrderKind::Limit);
835 assert_eq!(
836 orders.orders[0].time_in_force,
837 LighterOrderTimeInForce::GoodTillTime,
838 );
839 assert_eq!(orders.orders[0].status, LighterOrderStatus::Open);
840 assert_eq!(orders.orders[0].trigger_status, LighterTriggerStatus::Na);
841 assert_eq!(orders.orders[0].side, Some(LighterOrderSide::Sell));
842 assert!(orders.orders[0].is_ask);
843 }
844
845 #[rstest]
846 fn test_orders_allows_empty_side_with_is_ask() {
847 let mut value: serde_json::Value = serde_json::from_str(HTTP_ORDERS).unwrap();
848 value["orders"][0]["side"] = serde_json::Value::String(String::new());
849
850 let orders: LighterOrders = serde_json::from_value(value).unwrap();
851
852 assert_eq!(orders.orders[0].side, None);
853 assert!(orders.orders[0].is_ask);
854 }
855}