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;
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/// Response payload of `GET /api/v1/nextNonce`.
42///
43/// The venue reports the next sequence number it expects on `sendTx` for the
44/// `(account_index, api_key_index)` pair. Pass this to
45/// [`crate::signing::nonce::NonceManager::refresh`] before the first signed
46/// transaction.
47#[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/// Response payload of `GET /api/v1/tx`.
55#[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/// One account row from `GET /api/v1/account`.
71///
72/// Models only the fields the adapter consumes; the venue response carries
73/// many more, which are ignored on deserialization.
74#[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/// Response payload of `GET /api/v1/account`.
83#[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/// Response payload of `GET /api/v1/getMakerOnlyApiKeys`.
93///
94/// Lighter restricts maker-only keys to the 0ms speed-bump lane (PostOnly
95/// creates, modifies on ALO orders, cancel / cancel-all). Any tx kind outside
96/// that allowlist - for example `ApproveIntegrator` (tx_type 45) - is rejected
97/// with venue code `62007`. The adapter pre-flights this endpoint before
98/// submitting the integrator auto-approval so it can skip the doomed tx with
99/// a clear log line instead of swallowing the misleading 62007.
100#[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/// A single book level returned by the order-book REST snapshot and the
336/// WebSocket ticker / order_book / order_book_depth10 frames.
337///
338/// Lighter sends empty strings on the bid or ask side of a ticker frame when
339/// that side currently has no resting orders; [`deserialize_decimal_or_zero`]
340/// maps those to [`Decimal::ZERO`]. Downstream parsers must treat a
341/// zero-priced (or zero-sized) level as an uninhabited side, not a literal
342/// zero quote.
343#[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    // Lighter sends an empty string when no trigger price is set.
502    #[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}