Skip to main content

nautilus_lighter/http/
models.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//! Request and response payload definitions for Lighter REST endpoints.
17
18use 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;
27
28use crate::common::enums::{
29    LighterCandleResolution, LighterFundingResolution, LighterMarketStatus, LighterOrderKind,
30    LighterOrderSide, LighterOrderStatus, LighterOrderTimeInForce, LighterPositionMarginMode,
31    LighterProductType, LighterTradeType, LighterTriggerStatus,
32};
33
34#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
35pub struct LighterResultCode {
36    pub code: i32,
37    pub message: Option<String>,
38}
39
40/// Response payload of `GET /api/v1/nextNonce`.
41///
42/// The venue reports the next sequence number it expects on `sendTx` for the
43/// `(account_index, api_key_index)` pair. Pass this to
44/// [`crate::signing::nonce::NonceManager::refresh`] before the first signed
45/// transaction.
46#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
47pub struct LighterNextNonce {
48    pub code: i32,
49    pub message: Option<String>,
50    pub nonce: i64,
51}
52
53/// One account row from `GET /api/v1/account`.
54///
55/// Models only the fields the adapter consumes; the venue response carries
56/// many more, which are ignored on deserialization.
57#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
58pub struct LighterAccountDetail {
59    pub account_index: u64,
60    pub account_type: u8,
61    pub status: i32,
62}
63
64/// Response payload of `GET /api/v1/account`.
65#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
66pub struct LighterAccountsResponse {
67    pub code: i32,
68    pub message: Option<String>,
69    pub total: i64,
70    pub accounts: Vec<LighterAccountDetail>,
71}
72
73/// Response payload of `GET /api/v1/getMakerOnlyApiKeys`.
74///
75/// Lighter restricts maker-only keys to the 0ms speed-bump lane (PostOnly
76/// creates, modifies on ALO orders, cancel / cancel-all). Any tx kind outside
77/// that allowlist — for example `ApproveIntegrator` (tx_type 45) — is rejected
78/// with venue code `62007`. The adapter pre-flights this endpoint before
79/// submitting the integrator auto-approval so it can skip the doomed tx with
80/// a clear log line instead of swallowing the misleading 62007.
81#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
82pub struct LighterMakerOnlyApiKeys {
83    pub code: i32,
84    pub message: Option<String>,
85    #[serde(default)]
86    pub api_key_indexes: Vec<i64>,
87}
88
89#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
90pub struct LighterSendTxRequest {
91    pub tx_type: u8,
92    pub tx_info: String,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub price_protection: Option<bool>,
95}
96
97impl LighterSendTxRequest {
98    #[must_use]
99    pub fn new(tx_type: u8, tx_info: impl Into<String>) -> Self {
100        Self {
101            tx_type,
102            tx_info: tx_info.into(),
103            price_protection: None,
104        }
105    }
106
107    #[must_use]
108    pub const fn with_price_protection(mut self, price_protection: bool) -> Self {
109        self.price_protection = Some(price_protection);
110        self
111    }
112
113    pub(crate) fn form_fields(&self) -> Vec<(&'static str, String)> {
114        let mut fields = vec![
115            ("tx_type", self.tx_type.to_string()),
116            ("tx_info", self.tx_info.clone()),
117        ];
118
119        if let Some(price_protection) = self.price_protection {
120            fields.push(("price_protection", price_protection.to_string()));
121        }
122        fields
123    }
124}
125
126#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
127pub struct LighterSendTxBatchRequest {
128    pub tx_types: String,
129    pub tx_infos: String,
130}
131
132impl LighterSendTxBatchRequest {
133    #[must_use]
134    pub fn new(tx_types: impl Into<String>, tx_infos: impl Into<String>) -> Self {
135        Self {
136            tx_types: tx_types.into(),
137            tx_infos: tx_infos.into(),
138        }
139    }
140
141    pub(crate) fn form_fields(&self) -> Vec<(&'static str, String)> {
142        vec![
143            ("tx_types", self.tx_types.clone()),
144            ("tx_infos", self.tx_infos.clone()),
145        ]
146    }
147}
148
149#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
150pub struct LighterSendTxResponse {
151    pub code: i32,
152    pub message: Option<String>,
153    pub tx_hash: String,
154    pub predicted_execution_time_ms: i64,
155    pub volume_quota_remaining: Option<i64>,
156}
157
158#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
159pub struct LighterSendTxBatchResponse {
160    pub code: i32,
161    pub message: Option<String>,
162    pub tx_hash: Vec<String>,
163    pub predicted_execution_time_ms: i64,
164    pub volume_quota_remaining: Option<i64>,
165}
166
167#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
168pub struct LighterOrderBooks {
169    pub code: i32,
170    pub message: Option<String>,
171    #[serde(default, deserialize_with = "deserialize_null_vec")]
172    pub order_books: Vec<LighterOrderBook>,
173}
174
175#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
176pub struct LighterOrderBook {
177    pub symbol: Ustr,
178    pub market_id: i16,
179    pub market_type: LighterProductType,
180    pub base_asset_id: i16,
181    pub quote_asset_id: i16,
182    pub status: LighterMarketStatus,
183    #[serde(deserialize_with = "deserialize_decimal_from_str")]
184    pub taker_fee: Decimal,
185    #[serde(deserialize_with = "deserialize_decimal_from_str")]
186    pub maker_fee: Decimal,
187    #[serde(deserialize_with = "deserialize_decimal_from_str")]
188    pub liquidation_fee: Decimal,
189    #[serde(deserialize_with = "deserialize_decimal_from_str")]
190    pub min_base_amount: Decimal,
191    #[serde(deserialize_with = "deserialize_decimal_from_str")]
192    pub min_quote_amount: Decimal,
193    #[serde(deserialize_with = "deserialize_decimal_from_str")]
194    pub order_quote_limit: Decimal,
195    pub supported_size_decimals: u8,
196    pub supported_price_decimals: u8,
197    pub supported_quote_decimals: u8,
198}
199
200#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
201pub struct LighterOrderBookDetails {
202    pub code: i32,
203    pub message: Option<String>,
204    #[serde(default, deserialize_with = "deserialize_null_vec")]
205    pub order_book_details: Vec<LighterPerpOrderBookDetail>,
206    #[serde(default, deserialize_with = "deserialize_null_vec")]
207    pub spot_order_book_details: Vec<LighterSpotOrderBookDetail>,
208}
209
210#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
211pub struct LighterPerpOrderBookDetail {
212    #[serde(flatten)]
213    pub order_book: LighterOrderBook,
214    pub size_decimals: u8,
215    pub price_decimals: u8,
216    pub quote_multiplier: i64,
217    pub default_initial_margin_fraction: u16,
218    pub min_initial_margin_fraction: u16,
219    pub maintenance_margin_fraction: u16,
220    pub closeout_margin_fraction: u16,
221    #[serde(deserialize_with = "deserialize_decimal")]
222    pub last_trade_price: Decimal,
223    pub daily_trades_count: i64,
224    #[serde(deserialize_with = "deserialize_decimal")]
225    pub daily_base_token_volume: Decimal,
226    #[serde(deserialize_with = "deserialize_decimal")]
227    pub daily_quote_token_volume: Decimal,
228    #[serde(deserialize_with = "deserialize_decimal")]
229    pub daily_price_low: Decimal,
230    #[serde(deserialize_with = "deserialize_decimal")]
231    pub daily_price_high: Decimal,
232    #[serde(deserialize_with = "deserialize_decimal")]
233    pub daily_price_change: Decimal,
234    #[serde(deserialize_with = "deserialize_decimal")]
235    pub open_interest: Decimal,
236    #[serde(deserialize_with = "deserialize_decimal_btree_map")]
237    pub daily_chart: BTreeMap<String, Decimal>,
238    pub market_config: LighterMarketConfig,
239    pub strategy_index: u8,
240}
241
242#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
243pub struct LighterSpotOrderBookDetail {
244    #[serde(flatten)]
245    pub order_book: LighterOrderBook,
246    pub size_decimals: u8,
247    pub price_decimals: u8,
248    #[serde(deserialize_with = "deserialize_decimal")]
249    pub last_trade_price: Decimal,
250    pub daily_trades_count: i64,
251    #[serde(deserialize_with = "deserialize_decimal")]
252    pub daily_base_token_volume: Decimal,
253    #[serde(deserialize_with = "deserialize_decimal")]
254    pub daily_quote_token_volume: Decimal,
255    #[serde(deserialize_with = "deserialize_decimal")]
256    pub daily_price_low: Decimal,
257    #[serde(deserialize_with = "deserialize_decimal")]
258    pub daily_price_high: Decimal,
259    #[serde(deserialize_with = "deserialize_decimal")]
260    pub daily_price_change: Decimal,
261    #[serde(deserialize_with = "deserialize_decimal_btree_map")]
262    pub daily_chart: BTreeMap<String, Decimal>,
263}
264
265#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
266pub struct LighterMarketConfig {
267    pub market_margin_mode: LighterPositionMarginMode,
268    pub insurance_fund_account_index: i64,
269    pub liquidation_mode: i32,
270    pub force_reduce_only: bool,
271    pub trading_hours: String,
272    pub funding_fee_discounts_enabled: bool,
273    pub hidden: bool,
274}
275
276#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
277pub struct LighterOrderBookOrders {
278    pub code: i32,
279    pub message: Option<String>,
280    pub total_asks: i64,
281    #[serde(default, deserialize_with = "deserialize_null_vec")]
282    pub asks: Vec<LighterSimpleOrder>,
283    pub total_bids: i64,
284    #[serde(default, deserialize_with = "deserialize_null_vec")]
285    pub bids: Vec<LighterSimpleOrder>,
286}
287
288#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
289pub struct LighterSimpleOrder {
290    pub order_index: i64,
291    pub order_id: String,
292    pub owner_account_index: i64,
293    #[serde(deserialize_with = "deserialize_decimal_from_str")]
294    pub initial_base_amount: Decimal,
295    #[serde(deserialize_with = "deserialize_decimal_from_str")]
296    pub remaining_base_amount: Decimal,
297    #[serde(deserialize_with = "deserialize_decimal_from_str")]
298    pub price: Decimal,
299    pub order_expiry: i64,
300    pub transaction_time: i64,
301}
302
303#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
304pub struct LighterOrderBookDepth {
305    pub code: i32,
306    pub message: Option<String>,
307    #[serde(default, deserialize_with = "deserialize_null_vec")]
308    pub asks: Vec<LighterPriceLevel>,
309    #[serde(default, deserialize_with = "deserialize_null_vec")]
310    pub bids: Vec<LighterPriceLevel>,
311    pub offset: i64,
312    pub nonce: i64,
313}
314
315/// A single book level returned by the order-book REST snapshot and the
316/// WebSocket ticker / order_book / order_book_depth10 frames.
317///
318/// Lighter sends empty strings on the bid or ask side of a ticker frame when
319/// that side currently has no resting orders; [`deserialize_decimal_or_zero`]
320/// maps those to [`Decimal::ZERO`]. Downstream parsers must treat a
321/// zero-priced (or zero-sized) level as an uninhabited side, not a literal
322/// zero quote.
323#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
324pub struct LighterPriceLevel {
325    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
326    pub price: Decimal,
327    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
328    pub size: Decimal,
329}
330
331#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
332pub struct LighterTrades {
333    pub code: i32,
334    pub message: Option<String>,
335    pub next_cursor: Option<String>,
336    #[serde(default, deserialize_with = "deserialize_null_vec")]
337    pub trades: Vec<LighterTrade>,
338}
339
340#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
341pub struct LighterCandles {
342    pub code: i32,
343    pub message: Option<String>,
344    #[serde(rename = "r")]
345    pub resolution: LighterCandleResolution,
346    #[serde(rename = "c", default, deserialize_with = "deserialize_null_vec")]
347    pub candles: Vec<LighterCandle>,
348}
349
350#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
351pub struct LighterCandle {
352    #[serde(rename = "t")]
353    pub timestamp: i64,
354    #[serde(rename = "o", default, deserialize_with = "deserialize_decimal")]
355    pub open: Decimal,
356    #[serde(rename = "h", default, deserialize_with = "deserialize_decimal")]
357    pub high: Decimal,
358    #[serde(rename = "l", default, deserialize_with = "deserialize_decimal")]
359    pub low: Decimal,
360    #[serde(rename = "c", default, deserialize_with = "deserialize_decimal")]
361    pub close: Decimal,
362    #[serde(rename = "v", default, deserialize_with = "deserialize_decimal")]
363    pub volume_base: Decimal,
364    #[serde(rename = "V", default, deserialize_with = "deserialize_decimal")]
365    pub volume_quote: Decimal,
366    #[serde(rename = "i")]
367    pub last_trade_id: i64,
368}
369
370#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
371pub struct LighterFundings {
372    pub code: i32,
373    pub message: Option<String>,
374    pub resolution: LighterFundingResolution,
375    #[serde(default, deserialize_with = "deserialize_null_vec")]
376    pub fundings: Vec<LighterFunding>,
377}
378
379#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
380pub struct LighterFunding {
381    pub timestamp: i64,
382    #[serde(deserialize_with = "deserialize_decimal")]
383    pub value: Decimal,
384    #[serde(deserialize_with = "deserialize_decimal")]
385    pub rate: Decimal,
386    pub direction: LighterFundingDirection,
387}
388
389#[derive(Copy, Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
390#[serde(rename_all = "lowercase")]
391pub enum LighterFundingDirection {
392    Long,
393    Short,
394}
395
396#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
397pub struct LighterTrade {
398    pub trade_id: i64,
399    pub trade_id_str: Option<String>,
400    pub tx_hash: String,
401    #[serde(rename = "type")]
402    pub trade_type: LighterTradeType,
403    pub market_id: i16,
404    #[serde(deserialize_with = "deserialize_decimal_from_str")]
405    pub size: Decimal,
406    #[serde(deserialize_with = "deserialize_decimal_from_str")]
407    pub price: Decimal,
408    #[serde(deserialize_with = "deserialize_decimal_from_str")]
409    pub usd_amount: Decimal,
410    pub ask_id: i64,
411    pub ask_id_str: Option<String>,
412    pub bid_id: i64,
413    pub bid_id_str: Option<String>,
414    pub ask_client_id: i64,
415    pub ask_client_id_str: Option<String>,
416    pub bid_client_id: i64,
417    pub bid_client_id_str: Option<String>,
418    pub ask_account_id: i64,
419    pub bid_account_id: i64,
420    pub is_maker_ask: bool,
421    pub block_height: i64,
422    pub timestamp: i64,
423    pub taker_fee: Option<i32>,
424    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
425    pub taker_position_size_before: Option<Decimal>,
426    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
427    pub taker_entry_quote_before: Option<Decimal>,
428    pub taker_initial_margin_fraction_before: Option<u16>,
429    pub taker_position_sign_changed: Option<bool>,
430    pub maker_fee: Option<i32>,
431    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
432    pub maker_position_size_before: Option<Decimal>,
433    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
434    pub maker_entry_quote_before: Option<Decimal>,
435    pub maker_initial_margin_fraction_before: Option<u16>,
436    pub maker_position_sign_changed: Option<bool>,
437    pub transaction_time: i64,
438    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
439    pub ask_account_pnl: Option<Decimal>,
440    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
441    pub bid_account_pnl: Option<Decimal>,
442}
443
444#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
445pub struct LighterOrders {
446    pub code: i32,
447    pub message: Option<String>,
448    pub next_cursor: Option<String>,
449    #[serde(default, deserialize_with = "deserialize_null_vec")]
450    pub orders: Vec<LighterOrder>,
451}
452
453#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
454pub struct LighterOrder {
455    pub order_index: i64,
456    pub client_order_index: i64,
457    pub order_id: String,
458    pub client_order_id: String,
459    pub market_index: i16,
460    pub owner_account_index: i64,
461    #[serde(deserialize_with = "deserialize_decimal_from_str")]
462    pub initial_base_amount: Decimal,
463    #[serde(deserialize_with = "deserialize_decimal_from_str")]
464    pub price: Decimal,
465    pub nonce: i64,
466    #[serde(deserialize_with = "deserialize_decimal_from_str")]
467    pub remaining_base_amount: Decimal,
468    pub is_ask: bool,
469    pub base_size: i64,
470    pub base_price: i32,
471    #[serde(deserialize_with = "deserialize_decimal_from_str")]
472    pub filled_base_amount: Decimal,
473    #[serde(deserialize_with = "deserialize_decimal_from_str")]
474    pub filled_quote_amount: Decimal,
475    #[serde(default, deserialize_with = "deserialize_order_side")]
476    pub side: Option<LighterOrderSide>,
477    #[serde(rename = "type")]
478    pub order_type: LighterOrderKind,
479    pub time_in_force: LighterOrderTimeInForce,
480    pub reduce_only: bool,
481    // Lighter sends an empty string when no trigger price is set.
482    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
483    pub trigger_price: Decimal,
484    pub order_expiry: i64,
485    pub status: LighterOrderStatus,
486    pub trigger_status: LighterTriggerStatus,
487    pub trigger_time: i64,
488    pub parent_order_index: i64,
489    pub parent_order_id: String,
490    pub to_trigger_order_id_0: String,
491    pub to_trigger_order_id_1: String,
492    pub to_cancel_order_id_0: String,
493    pub integrator_fee_collector_index: String,
494    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
495    pub integrator_taker_fee: Decimal,
496    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
497    pub integrator_maker_fee: Decimal,
498    pub block_height: i64,
499    pub timestamp: i64,
500    pub created_at: i64,
501    pub updated_at: i64,
502    pub transaction_time: i64,
503}
504
505#[derive(Deserialize)]
506struct DecimalFromAny(#[serde(deserialize_with = "deserialize_decimal")] Decimal);
507
508fn deserialize_decimal_btree_map<'de, D>(
509    deserializer: D,
510) -> Result<BTreeMap<String, Decimal>, D::Error>
511where
512    D: serde::Deserializer<'de>,
513{
514    BTreeMap::<String, DecimalFromAny>::deserialize(deserializer).map(|values| {
515        values
516            .into_iter()
517            .map(|(key, decimal)| (key, decimal.0))
518            .collect()
519    })
520}
521
522fn deserialize_null_vec<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
523where
524    D: serde::Deserializer<'de>,
525    T: Deserialize<'de>,
526{
527    Option::<Vec<T>>::deserialize(deserializer).map(Option::unwrap_or_default)
528}
529
530fn deserialize_order_side<'de, D>(deserializer: D) -> Result<Option<LighterOrderSide>, D::Error>
531where
532    D: serde::Deserializer<'de>,
533{
534    let value = Option::<String>::deserialize(deserializer)?;
535    match value.as_deref() {
536        None | Some("") => Ok(None),
537        Some(s) => LighterOrderSide::from_str(s)
538            .map(Some)
539            .map_err(|_| de::Error::unknown_variant(s, &["buy", "sell", ""])),
540    }
541}
542
543#[cfg(test)]
544mod tests {
545    use rstest::rstest;
546
547    use super::*;
548
549    const HTTP_ORDER_BOOK_DETAILS: &str =
550        include_str!("../../test_data/http_order_book_details.json");
551    const HTTP_RECENT_TRADES: &str = include_str!("../../test_data/http_recent_trades.json");
552    const HTTP_RECENT_TRADES_MISSING: &str =
553        include_str!("../../test_data/http_recent_trades_missing.json");
554    const HTTP_RECENT_TRADES_NULL: &str =
555        include_str!("../../test_data/http_recent_trades_null.json");
556    const HTTP_ORDER_BOOKS: &str = include_str!("../../test_data/http_order_books.json");
557    const HTTP_ORDER_BOOK_ORDERS: &str =
558        include_str!("../../test_data/http_order_book_orders.json");
559    const HTTP_ORDER_BOOK_DEPTH: &str = include_str!("../../test_data/http_order_book_depth.json");
560    const HTTP_ORDER_BOOK_DEPTH_NULL: &str =
561        include_str!("../../test_data/http_order_book_depth_null.json");
562    const HTTP_ORDERS: &str = include_str!("../../test_data/http_orders.json");
563    const HTTP_CANDLES: &str = include_str!("../../test_data/http_candles.json");
564    const HTTP_CANDLES_NULL: &str = include_str!("../../test_data/http_candles_null.json");
565    const HTTP_FUNDINGS: &str = include_str!("../../test_data/http_fundings.json");
566    const HTTP_ACCOUNT: &str = include_str!("../../test_data/http_account.json");
567
568    #[rstest]
569    fn test_account_response_deserializes_live_shape() {
570        let response: LighterAccountsResponse = serde_json::from_str(HTTP_ACCOUNT).unwrap();
571
572        assert_eq!(response.code, 200);
573        assert_eq!(response.total, 1);
574        assert_eq!(response.accounts.len(), 1);
575        let account = &response.accounts[0];
576        assert_eq!(account.account_index, 123_456);
577        assert_eq!(account.account_type, 0);
578        assert_eq!(account.status, 1);
579    }
580
581    #[rstest]
582    fn test_order_book_details_deserializes_live_shape() {
583        let details: LighterOrderBookDetails =
584            serde_json::from_str(HTTP_ORDER_BOOK_DETAILS).unwrap();
585
586        assert_eq!(details.code, 200);
587        assert_eq!(details.order_book_details.len(), 1);
588        assert_eq!(
589            details.order_book_details[0].order_book.market_type,
590            LighterProductType::Perp,
591        );
592        assert_eq!(details.order_book_details[0].price_decimals, 2);
593        assert_eq!(
594            details.order_book_details[0].last_trade_price,
595            Decimal::new(236_131, 2),
596        );
597        assert_eq!(
598            details.order_book_details[0].daily_base_token_volume,
599            Decimal::new(1_953_991_521, 4),
600        );
601        assert_eq!(
602            details.order_book_details[0]
603                .market_config
604                .market_margin_mode,
605            LighterPositionMarginMode::Cross,
606        );
607        assert!(details.spot_order_book_details.is_empty());
608    }
609
610    #[rstest]
611    fn test_recent_trades_allow_omitted_fee_fields() {
612        let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES).unwrap();
613
614        assert_eq!(trades.trades.len(), 1);
615        assert_eq!(
616            trades.trades[0].trade_id_str.as_deref(),
617            Some("19211490282")
618        );
619        assert_eq!(trades.trades[0].taker_fee, None);
620        assert_eq!(trades.trades[0].maker_fee, Some(28));
621    }
622
623    #[rstest]
624    fn test_recent_trades_deserializes_null_trades_as_empty() {
625        let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES_NULL).unwrap();
626
627        assert_eq!(trades.code, 200);
628        assert!(trades.trades.is_empty());
629    }
630
631    #[rstest]
632    fn test_recent_trades_deserializes_missing_trades_as_empty() {
633        let trades: LighterTrades = serde_json::from_str(HTTP_RECENT_TRADES_MISSING).unwrap();
634
635        assert_eq!(trades.code, 200);
636        assert!(trades.trades.is_empty());
637    }
638
639    #[rstest]
640    fn test_candles_deserializes_live_shape_with_omitted_raw_fields() {
641        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
642
643        assert_eq!(candles.code, 200);
644        assert_eq!(candles.resolution, LighterCandleResolution::OneMinute);
645        assert_eq!(candles.candles.len(), 2);
646        assert_eq!(candles.candles[0].timestamp, 1_700_000_000_000);
647        assert_eq!(candles.candles[0].open, Decimal::new(236_111, 2));
648        assert_eq!(candles.candles[0].high, Decimal::new(236_222, 2));
649        assert_eq!(candles.candles[0].low, Decimal::new(236_000, 2));
650        assert_eq!(candles.candles[0].close, Decimal::new(236_131, 2));
651        assert_eq!(candles.candles[0].volume_base, Decimal::new(12_345, 4));
652        assert_eq!(candles.candles[0].last_trade_id, 19_211_490_282);
653    }
654
655    #[rstest]
656    fn test_candles_deserializes_null_candles_as_empty() {
657        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES_NULL).unwrap();
658
659        assert_eq!(candles.code, 200);
660        assert_eq!(candles.resolution, LighterCandleResolution::OneMinute);
661        assert!(candles.candles.is_empty());
662    }
663
664    #[rstest]
665    fn test_fundings_deserializes_live_shape() {
666        let fundings: LighterFundings = serde_json::from_str(HTTP_FUNDINGS).unwrap();
667
668        assert_eq!(fundings.code, 200);
669        assert_eq!(fundings.resolution, LighterFundingResolution::OneHour);
670        assert_eq!(fundings.fundings.len(), 2);
671        assert_eq!(fundings.fundings[0].timestamp, 1_778_702_400);
672        assert_eq!(fundings.fundings[0].rate, Decimal::new(12, 4));
673        assert_eq!(
674            fundings.fundings[0].direction,
675            LighterFundingDirection::Long
676        );
677        assert_eq!(
678            fundings.fundings[1].direction,
679            LighterFundingDirection::Short
680        );
681    }
682
683    #[rstest]
684    fn test_order_books_deserializes_live_shape() {
685        let order_books: LighterOrderBooks = serde_json::from_str(HTTP_ORDER_BOOKS).unwrap();
686
687        assert_eq!(order_books.code, 200);
688        assert_eq!(order_books.order_books.len(), 1);
689        assert_eq!(order_books.order_books[0].symbol, Ustr::from("ETH"));
690        assert_eq!(
691            order_books.order_books[0].market_type,
692            LighterProductType::Perp
693        );
694        assert_eq!(
695            order_books.order_books[0].status,
696            LighterMarketStatus::Active
697        );
698        assert_eq!(order_books.order_books[0].supported_price_decimals, 2);
699    }
700
701    #[rstest]
702    fn test_order_book_orders_deserializes_live_shape() {
703        let book: LighterOrderBookOrders = serde_json::from_str(HTTP_ORDER_BOOK_ORDERS).unwrap();
704
705        assert_eq!(book.total_asks, 1);
706        assert_eq!(book.asks[0].order_id, "281476929689581");
707        assert_eq!(book.asks[0].price, Decimal::from_str("2361.32").unwrap());
708        assert_eq!(book.total_bids, 1);
709        assert_eq!(
710            book.bids[0].remaining_base_amount,
711            Decimal::from_str("3.4125").unwrap(),
712        );
713    }
714
715    #[rstest]
716    fn test_order_book_depth_deserializes_live_shape() {
717        let depth: LighterOrderBookDepth = serde_json::from_str(HTTP_ORDER_BOOK_DEPTH).unwrap();
718
719        assert_eq!(depth.code, 200);
720        assert_eq!(depth.asks[0].price, Decimal::from_str("2352.74").unwrap());
721        assert_eq!(depth.bids[0].size, Decimal::from_str("0.2125").unwrap());
722        assert_eq!(depth.offset, 1_558_300);
723        assert_eq!(depth.nonce, 9_182_390_020);
724    }
725
726    #[rstest]
727    fn test_order_book_depth_deserializes_null_sides_as_empty() {
728        let depth: LighterOrderBookDepth =
729            serde_json::from_str(HTTP_ORDER_BOOK_DEPTH_NULL).unwrap();
730
731        assert_eq!(depth.code, 200);
732        assert!(depth.asks.is_empty());
733        assert!(depth.bids.is_empty());
734        assert_eq!(depth.offset, 1);
735        assert_eq!(depth.nonce, 0);
736    }
737
738    #[rstest]
739    fn test_orders_deserializes_live_shape() {
740        let orders: LighterOrders = serde_json::from_str(HTTP_ORDERS).unwrap();
741
742        assert_eq!(orders.next_cursor.as_deref(), Some("cursor-1"));
743        assert_eq!(orders.orders.len(), 1);
744        assert_eq!(orders.orders[0].order_type, LighterOrderKind::Limit);
745        assert_eq!(
746            orders.orders[0].time_in_force,
747            LighterOrderTimeInForce::GoodTillTime,
748        );
749        assert_eq!(orders.orders[0].status, LighterOrderStatus::Open);
750        assert_eq!(orders.orders[0].trigger_status, LighterTriggerStatus::Na);
751        assert_eq!(orders.orders[0].side, Some(LighterOrderSide::Sell));
752        assert!(orders.orders[0].is_ask);
753    }
754
755    #[rstest]
756    fn test_orders_allows_empty_side_with_is_ask() {
757        let mut value: serde_json::Value = serde_json::from_str(HTTP_ORDERS).unwrap();
758        value["orders"][0]["side"] = serde_json::Value::String(String::new());
759
760        let orders: LighterOrders = serde_json::from_value(value).unwrap();
761
762        assert_eq!(orders.orders[0].side, None);
763        assert!(orders.orders[0].is_ask);
764    }
765}