Skip to main content

nautilus_lighter/http/
parse.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//! Parsers from Lighter REST payloads to Nautilus domain types.
17
18use anyhow::Context;
19use nautilus_core::UnixNanos;
20use nautilus_model::{
21    data::{
22        Bar, BarType, BookOrder, FundingRateUpdate, OrderBookDelta, OrderBookDeltas, TradeTick,
23    },
24    enums::{AggressorSide, BookAction, BookType, OrderSide, RecordFlag},
25    identifiers::{InstrumentId, Symbol, TradeId},
26    instruments::{CryptoPerpetual, CurrencyPair, Instrument, InstrumentAny},
27    orderbook::OrderBook,
28    types::{Currency, Money, Price, Quantity},
29};
30use rust_decimal::Decimal;
31
32use crate::{
33    common::{
34        enums::LighterMarketStatus,
35        parse::{
36            parse_millis_to_nanos, parse_secs_to_nanos, price_from_decimal, quantity_from_decimal,
37        },
38        symbol::{MarketRegistry, format_instrument_id_with_venue},
39    },
40    http::models::{
41        LighterCandle, LighterFunding, LighterFundingDirection, LighterOrderBook,
42        LighterOrderBookOrders, LighterPerpOrderBookDetail, LighterSimpleOrder,
43        LighterSpotOrderBookDetail, LighterTrade,
44    },
45};
46
47pub fn register_order_books(registry: &MarketRegistry, order_books: &[LighterOrderBook]) {
48    for order_book in order_books {
49        register_order_book(registry, order_book);
50    }
51}
52
53pub fn register_perp_order_book_details(
54    registry: &MarketRegistry,
55    details: &[LighterPerpOrderBookDetail],
56) {
57    for detail in details {
58        register_order_book(registry, &detail.order_book);
59    }
60}
61
62pub fn register_spot_order_book_details(
63    registry: &MarketRegistry,
64    details: &[LighterSpotOrderBookDetail],
65) {
66    for detail in details {
67        register_order_book(registry, &detail.order_book);
68    }
69}
70
71/// Parses Lighter order book metadata into Nautilus instruments and registers their market ids.
72///
73/// # Errors
74///
75/// Returns an error if metadata is nonempty but no instrument definition can be converted.
76pub fn parse_order_book_details_instruments(
77    registry: &MarketRegistry,
78    perp_details: &[LighterPerpOrderBookDetail],
79    spot_details: &[LighterSpotOrderBookDetail],
80    ts_init: UnixNanos,
81) -> anyhow::Result<Vec<InstrumentAny>> {
82    parse_order_book_details_instruments_with_status(registry, perp_details, spot_details, ts_init)
83        .map(|instruments| {
84            instruments
85                .into_iter()
86                .map(|(instrument, _)| instrument)
87                .collect()
88        })
89}
90
91/// Parses Lighter order book metadata into Nautilus instruments and market statuses.
92///
93/// # Errors
94///
95/// Returns an error if metadata is nonempty but no instrument definition can be converted.
96pub fn parse_order_book_details_instruments_with_status(
97    registry: &MarketRegistry,
98    perp_details: &[LighterPerpOrderBookDetail],
99    spot_details: &[LighterSpotOrderBookDetail],
100    ts_init: UnixNanos,
101) -> anyhow::Result<Vec<(InstrumentAny, LighterMarketStatus)>> {
102    let mut instruments = Vec::with_capacity(perp_details.len() + spot_details.len());
103    let mut first_error = None;
104
105    for detail in perp_details {
106        match parse_perp_instrument(registry, detail, ts_init) {
107            Ok(instrument) => instruments.push((instrument, detail.order_book.status)),
108            Err(e) => {
109                log::warn!(
110                    "Skipping invalid Lighter perpetual instrument `{}`: {e}",
111                    detail.order_book.symbol,
112                );
113                first_error.get_or_insert_with(|| e.to_string());
114            }
115        }
116    }
117
118    for detail in spot_details {
119        match parse_spot_instrument(registry, detail, ts_init) {
120            Ok(instrument) => instruments.push((instrument, detail.order_book.status)),
121            Err(e) => {
122                log::warn!(
123                    "Skipping invalid Lighter spot instrument `{}`: {e}",
124                    detail.order_book.symbol,
125                );
126                first_error.get_or_insert_with(|| e.to_string());
127            }
128        }
129    }
130
131    let input_len = perp_details.len() + spot_details.len();
132    if input_len > 0 && instruments.is_empty() {
133        anyhow::bail!(
134            "failed to parse any of {input_len} Lighter instruments: {}",
135            first_error.as_deref().unwrap_or("unknown parse error"),
136        );
137    }
138
139    Ok(instruments)
140}
141
142/// Parses a Lighter trade into a Nautilus [`TradeTick`].
143///
144/// # Errors
145///
146/// Returns an error if the price, size, timestamp, or trade id is invalid.
147pub fn parse_trade_tick(
148    trade: &LighterTrade,
149    instrument: &InstrumentAny,
150    ts_init: UnixNanos,
151) -> anyhow::Result<TradeTick> {
152    let price = price_from_decimal(trade.price, instrument.price_precision())?;
153    let size = quantity_from_decimal(trade.size, instrument.size_precision())?;
154    let aggressor_side = aggressor_side_from_is_maker_ask(trade.is_maker_ask);
155    let trade_id = match trade.trade_id_str.as_deref() {
156        Some(s) => TradeId::new_checked(s),
157        None => TradeId::new_checked(trade.trade_id.to_string()),
158    }
159    .context("invalid Lighter trade identifier")?;
160    let timestamp_ms =
161        u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
162    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
163
164    TradeTick::new_checked(
165        instrument.id(),
166        price,
167        size,
168        aggressor_side,
169        trade_id,
170        ts_event,
171        ts_init,
172    )
173    .context("failed to construct TradeTick from Lighter trade")
174}
175
176/// Parses a Lighter candle into a Nautilus [`Bar`].
177///
178/// # Errors
179///
180/// Returns an error if any price, volume, or timestamp field cannot be converted.
181pub fn parse_candle_bar(
182    candle: &LighterCandle,
183    bar_type: BarType,
184    instrument: &InstrumentAny,
185    ts_init: UnixNanos,
186) -> anyhow::Result<Bar> {
187    anyhow::ensure!(
188        candle.open > Decimal::ZERO,
189        "non-positive candle open `{}`",
190        candle.open
191    );
192    anyhow::ensure!(
193        candle.high > Decimal::ZERO,
194        "non-positive candle high `{}`",
195        candle.high
196    );
197    anyhow::ensure!(
198        candle.low > Decimal::ZERO,
199        "non-positive candle low `{}`",
200        candle.low
201    );
202    anyhow::ensure!(
203        candle.close > Decimal::ZERO,
204        "non-positive candle close `{}`",
205        candle.close
206    );
207
208    let timestamp_ms =
209        u64::try_from(candle.timestamp).context("negative Lighter candle timestamp")?;
210    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
211    let price_precision = instrument.price_precision();
212    let size_precision = instrument.size_precision();
213
214    let open = Price::from_decimal_dp(candle.open, price_precision)
215        .map_err(|e| anyhow::anyhow!("invalid candle open: {e}"))?;
216    let high = Price::from_decimal_dp(candle.high, price_precision)
217        .map_err(|e| anyhow::anyhow!("invalid candle high: {e}"))?;
218    let low = Price::from_decimal_dp(candle.low, price_precision)
219        .map_err(|e| anyhow::anyhow!("invalid candle low: {e}"))?;
220    let close = Price::from_decimal_dp(candle.close, price_precision)
221        .map_err(|e| anyhow::anyhow!("invalid candle close: {e}"))?;
222    anyhow::ensure!(
223        candle.volume_base.is_sign_positive(),
224        "negative candle volume `{}`",
225        candle.volume_base,
226    );
227    let volume = Quantity::from_decimal_dp(candle.volume_base, size_precision)
228        .map_err(|e| anyhow::anyhow!("invalid candle volume: {e}"))?;
229
230    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
231        .context("failed to construct Bar from Lighter candle")
232}
233
234/// Parses a Lighter historical funding row into a Nautilus [`FundingRateUpdate`].
235///
236/// Lighter returns `rate` as a magnitude and `direction` as the side paying
237/// the funding. Nautilus uses the conventional signed rate: positive when
238/// longs pay shorts and negative when shorts pay longs.
239///
240/// # Errors
241///
242/// Returns an error if the timestamp cannot be converted.
243pub fn parse_funding_rate_update(
244    funding: &LighterFunding,
245    instrument_id: InstrumentId,
246    interval: Option<u16>,
247    ts_init: UnixNanos,
248) -> anyhow::Result<FundingRateUpdate> {
249    let timestamp =
250        u64::try_from(funding.timestamp).context("negative Lighter funding timestamp")?;
251    let ts_event = parse_secs_to_nanos(timestamp)?;
252    let rate = match funding.direction {
253        LighterFundingDirection::Long => funding.rate,
254        LighterFundingDirection::Short => -funding.rate,
255    };
256
257    Ok(FundingRateUpdate::new(
258        instrument_id,
259        rate,
260        interval,
261        None,
262        ts_event,
263        ts_init,
264    ))
265}
266
267/// Parses an HTTP order book snapshot response into Nautilus order book deltas.
268///
269/// # Errors
270///
271/// Returns an error if any price or size cannot be converted.
272pub fn parse_order_book_snapshot(
273    snapshot: &LighterOrderBookOrders,
274    instrument_id: InstrumentId,
275    price_precision: u8,
276    size_precision: u8,
277    ts_event: UnixNanos,
278    ts_init: UnixNanos,
279) -> anyhow::Result<OrderBookDeltas> {
280    let total_levels = snapshot.bids.len() + snapshot.asks.len();
281    let mut deltas = Vec::with_capacity(total_levels + 1);
282    let mut clear = OrderBookDelta::clear(instrument_id, 0, ts_event, ts_init);
283
284    if total_levels == 0 {
285        clear.flags |= RecordFlag::F_LAST as u8;
286    }
287    deltas.push(clear);
288
289    let mut processed = 0_usize;
290
291    for bid in &snapshot.bids {
292        let price = price_from_decimal(bid.price, price_precision)?;
293        let size = quantity_from_decimal(bid.remaining_base_amount, size_precision)?;
294        let order_id =
295            u64::try_from(bid.order_index).context("negative Lighter bid order index")?;
296        let order = BookOrder::new(OrderSide::Buy, price, size, order_id);
297        processed += 1;
298        let sequence = processed as u64;
299        let delta = OrderBookDelta::new_checked(
300            instrument_id,
301            BookAction::Add,
302            order,
303            snapshot_flags(processed, total_levels),
304            sequence,
305            ts_event,
306            ts_init,
307        )
308        .context("failed to construct Lighter bid snapshot delta")?;
309        deltas.push(delta);
310    }
311
312    for ask in &snapshot.asks {
313        let price = price_from_decimal(ask.price, price_precision)?;
314        let size = quantity_from_decimal(ask.remaining_base_amount, size_precision)?;
315        let order_id =
316            u64::try_from(ask.order_index).context("negative Lighter ask order index")?;
317        let order = BookOrder::new(OrderSide::Sell, price, size, order_id);
318        processed += 1;
319        let sequence = processed as u64;
320        let delta = OrderBookDelta::new_checked(
321            instrument_id,
322            BookAction::Add,
323            order,
324            snapshot_flags(processed, total_levels),
325            sequence,
326            ts_event,
327            ts_init,
328        )
329        .context("failed to construct Lighter ask snapshot delta")?;
330        deltas.push(delta);
331    }
332
333    OrderBookDeltas::new_checked(instrument_id, deltas)
334        .context("failed to construct OrderBookDeltas from Lighter order book snapshot")
335}
336
337/// Parses an HTTP `orderBookOrders` snapshot into an aggregated [`OrderBook`].
338///
339/// The REST endpoint returns each resting order separately, while WebSocket
340/// `order_book` updates carry already-aggregated price levels. To keep
341/// snapshot semantics consistent with the live feed, sizes are summed per
342/// price on each side and added as a single L2 entry per level.
343///
344/// The snapshot itself carries no venue timestamp (Lighter's `LighterSimpleOrder`
345/// `transaction_time` is `0` for resting orders), so the constructed book's
346/// `ts_last` is left at [`UnixNanos::default`]. The first WebSocket delta
347/// applied after the snapshot will install a real venue timestamp and avoid
348/// spurious "out-of-order" warnings against a wall-clock placeholder.
349#[must_use]
350pub fn parse_l2_order_book_snapshot(
351    snapshot: &LighterOrderBookOrders,
352    instrument_id: InstrumentId,
353    price_precision: u8,
354    size_precision: u8,
355) -> OrderBook {
356    let mut book = OrderBook::new(instrument_id, BookType::L2_MBP);
357    let mut sequence: u64 = 0;
358    let ts_event = UnixNanos::default();
359
360    let bid_levels = aggregate_order_levels(&snapshot.bids, price_precision, size_precision);
361    let ask_levels = aggregate_order_levels(&snapshot.asks, price_precision, size_precision);
362
363    sequence += 1;
364    book.clear(sequence, ts_event);
365
366    for (price, size) in bid_levels {
367        sequence += 1;
368        let order = BookOrder::new(OrderSide::Buy, price, size, 0);
369        book.add(order, BookAction::Add as u8, sequence, ts_event);
370    }
371
372    for (price, size) in ask_levels {
373        sequence += 1;
374        let order = BookOrder::new(OrderSide::Sell, price, size, 0);
375        book.add(order, BookAction::Add as u8, sequence, ts_event);
376    }
377
378    book
379}
380
381fn aggregate_order_levels(
382    orders: &[LighterSimpleOrder],
383    price_precision: u8,
384    size_precision: u8,
385) -> Vec<(Price, Quantity)> {
386    use std::collections::BTreeMap;
387
388    let mut levels: BTreeMap<Decimal, Decimal> = BTreeMap::new();
389
390    for order in orders {
391        if !order.remaining_base_amount.is_sign_positive() || order.remaining_base_amount.is_zero()
392        {
393            continue;
394        }
395        *levels.entry(order.price).or_insert(Decimal::ZERO) += order.remaining_base_amount;
396    }
397
398    levels
399        .into_iter()
400        .filter_map(|(price, size)| {
401            let price = match Price::from_decimal_dp(price, price_precision) {
402                Ok(p) => p,
403                Err(e) => {
404                    log::warn!("Skipping Lighter snapshot price `{price}`: {e}");
405                    return None;
406                }
407            };
408            let size = match Quantity::from_decimal_dp(size, size_precision) {
409                Ok(q) => q,
410                Err(e) => {
411                    log::warn!("Skipping Lighter snapshot size `{size}`: {e}");
412                    return None;
413                }
414            };
415            Some((price, size))
416        })
417        .collect()
418}
419
420fn aggressor_side_from_is_maker_ask(is_maker_ask: bool) -> AggressorSide {
421    if is_maker_ask {
422        AggressorSide::Buy
423    } else {
424        AggressorSide::Sell
425    }
426}
427
428fn snapshot_flags(processed: usize, total_levels: usize) -> u8 {
429    let mut flags = RecordFlag::F_SNAPSHOT as u8;
430    if processed == total_levels {
431        flags |= RecordFlag::F_LAST as u8;
432    }
433    flags
434}
435
436fn register_order_book(registry: &MarketRegistry, order_book: &LighterOrderBook) {
437    registry.insert(
438        order_book.market_id,
439        order_book.symbol.as_str(),
440        order_book.market_type,
441    );
442}
443
444fn parse_perp_instrument(
445    registry: &MarketRegistry,
446    detail: &LighterPerpOrderBookDetail,
447    ts_init: UnixNanos,
448) -> anyhow::Result<InstrumentAny> {
449    let order_book = &detail.order_book;
450
451    let instrument_id = format_instrument_id_with_venue(
452        order_book.symbol.as_str(),
453        order_book.market_type,
454        registry.venue(),
455    );
456
457    let raw_symbol = Symbol::from_ustr_unchecked(order_book.symbol);
458    let settlement_currency = registry.settlement_currency();
459
460    let (base_currency, quote_currency) = symbol_currencies(
461        order_book.symbol.as_str(),
462        settlement_currency.code.as_str(),
463    );
464
465    let price_increment = price_increment(detail.price_decimals)?;
466    let size_increment = quantity_increment(detail.size_decimals)?;
467
468    let instrument = CryptoPerpetual::builder()
469        .instrument_id(instrument_id)
470        .raw_symbol(raw_symbol)
471        .base_currency(base_currency)
472        .quote_currency(quote_currency)
473        .settlement_currency(settlement_currency)
474        .is_inverse(false)
475        .price_precision(detail.price_decimals)
476        .size_precision(detail.size_decimals)
477        .price_increment(price_increment)
478        .size_increment(size_increment)
479        .maybe_min_quantity(min_quantity(order_book, detail.size_decimals)?)
480        .maybe_max_notional(max_notional(order_book, quote_currency)?)
481        .maybe_min_notional(min_notional(order_book, quote_currency)?)
482        .margin_init(margin_fraction(detail.default_initial_margin_fraction))
483        .margin_maint(margin_fraction(detail.maintenance_margin_fraction))
484        .ts_event(ts_init)
485        .ts_init(ts_init)
486        .build()
487        .map_err(|e| anyhow::anyhow!("{e}"))?;
488
489    registry.insert(
490        order_book.market_id,
491        order_book.symbol.as_str(),
492        order_book.market_type,
493    );
494
495    Ok(InstrumentAny::CryptoPerpetual(instrument))
496}
497
498fn parse_spot_instrument(
499    registry: &MarketRegistry,
500    detail: &LighterSpotOrderBookDetail,
501    ts_init: UnixNanos,
502) -> anyhow::Result<InstrumentAny> {
503    let order_book = &detail.order_book;
504
505    let instrument_id = format_instrument_id_with_venue(
506        order_book.symbol.as_str(),
507        order_book.market_type,
508        registry.venue(),
509    );
510
511    let raw_symbol = Symbol::from_ustr_unchecked(order_book.symbol);
512    let (base_currency, quote_currency) = spot_symbol_currencies(order_book.symbol.as_str())?;
513    let price_increment = price_increment(detail.price_decimals)?;
514    let size_increment = quantity_increment(detail.size_decimals)?;
515
516    let instrument = CurrencyPair::builder()
517        .instrument_id(instrument_id)
518        .raw_symbol(raw_symbol)
519        .base_currency(base_currency)
520        .quote_currency(quote_currency)
521        .price_precision(detail.price_decimals)
522        .size_precision(detail.size_decimals)
523        .price_increment(price_increment)
524        .size_increment(size_increment)
525        .maybe_min_quantity(min_quantity(order_book, detail.size_decimals)?)
526        .maybe_max_notional(max_notional(order_book, quote_currency)?)
527        .maybe_min_notional(min_notional(order_book, quote_currency)?)
528        .ts_event(ts_init)
529        .ts_init(ts_init)
530        .build()
531        .map_err(|e| anyhow::anyhow!("{e}"))?;
532
533    registry.insert(
534        order_book.market_id,
535        order_book.symbol.as_str(),
536        order_book.market_type,
537    );
538
539    Ok(InstrumentAny::CurrencyPair(instrument))
540}
541
542fn spot_symbol_currencies(symbol: &str) -> anyhow::Result<(Currency, Currency)> {
543    let (base, quote) = symbol
544        .split_once('/')
545        .context("Lighter spot symbol must use BASE/QUOTE format")?;
546    let base = base.trim();
547    let quote = quote.trim();
548    anyhow::ensure!(
549        !base.is_empty() && !quote.is_empty() && !quote.contains('/'),
550        "Lighter spot symbol must contain one nonempty BASE/QUOTE pair",
551    );
552
553    Ok((
554        Currency::get_or_create_crypto(base),
555        Currency::get_or_create_crypto(quote),
556    ))
557}
558
559fn symbol_currencies(symbol: &str, default_quote: &str) -> (Currency, Currency) {
560    let (base, quote) = symbol.split_once('/').unwrap_or((symbol, default_quote));
561    (
562        Currency::get_or_create_crypto(base),
563        Currency::get_or_create_crypto(quote),
564    )
565}
566
567fn price_increment(decimals: u8) -> anyhow::Result<Price> {
568    Price::from_decimal_dp(decimal_increment(decimals), decimals)
569        .map_err(|e| anyhow::anyhow!("{e}"))
570}
571
572fn quantity_increment(decimals: u8) -> anyhow::Result<Quantity> {
573    Quantity::from_decimal_dp(decimal_increment(decimals), decimals)
574        .map_err(|e| anyhow::anyhow!("{e}"))
575}
576
577// `10^-decimals` as an exact decimal (e.g. 3 -> 0.001, 0 -> 1).
578fn decimal_increment(decimals: u8) -> Decimal {
579    Decimal::new(1, u32::from(decimals))
580}
581
582fn min_quantity(
583    order_book: &LighterOrderBook,
584    size_decimals: u8,
585) -> anyhow::Result<Option<Quantity>> {
586    quantity_from_decimal(order_book.min_base_amount, size_decimals).map(Some)
587}
588
589fn min_notional(
590    order_book: &LighterOrderBook,
591    currency: Currency,
592) -> anyhow::Result<Option<Money>> {
593    money_from_decimal(order_book.min_quote_amount, currency).map(Some)
594}
595
596fn max_notional(
597    order_book: &LighterOrderBook,
598    currency: Currency,
599) -> anyhow::Result<Option<Money>> {
600    money_from_decimal(order_book.order_quote_limit, currency).map(Some)
601}
602
603fn money_from_decimal(value: Decimal, currency: Currency) -> anyhow::Result<Money> {
604    Money::from_decimal(value, currency).map_err(|e| anyhow::anyhow!("{e}"))
605}
606
607fn margin_fraction(value: u16) -> Decimal {
608    Decimal::from(value) / Decimal::from(10_000)
609}
610
611#[cfg(test)]
612mod tests {
613    use std::str::FromStr;
614
615    use nautilus_model::{
616        data::{BarSpecification, BarType},
617        enums::{AggregationSource, BarAggregation, PriceType},
618        identifiers::{InstrumentId, Symbol, Venue},
619        instruments::CryptoPerpetual,
620        types::{Money, Price, Quantity, currency::Currency},
621    };
622    use rstest::rstest;
623    use rust_decimal::Decimal;
624    use ustr::Ustr;
625
626    use super::*;
627    use crate::{
628        common::enums::{
629            LighterMarketStatus, LighterPositionMarginMode, LighterProductType, LighterTradeType,
630        },
631        http::models::{
632            LighterCandles, LighterFunding, LighterFundingDirection, LighterMarketConfig,
633            LighterOrderBookDetails, LighterSimpleOrder,
634        },
635    };
636
637    const HTTP_CANDLES: &str = include_str!("../../test_data/http_candles.json");
638    const HTTP_ORDER_BOOK_DETAILS_WIDENED_IDS: &str =
639        include_str!("../../test_data/http_order_book_details_widened_ids.json");
640
641    fn create_test_instrument() -> InstrumentAny {
642        let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
643
644        InstrumentAny::CryptoPerpetual(
645            CryptoPerpetual::builder()
646                .instrument_id(instrument_id)
647                .raw_symbol(Symbol::new("ETH-PERP"))
648                .base_currency(Currency::from("ETH"))
649                .quote_currency(Currency::from("USDC"))
650                .settlement_currency(Currency::from("USDC"))
651                .is_inverse(false)
652                .price_precision(2)
653                .size_precision(4)
654                .price_increment(Price::from("0.01"))
655                .size_increment(Quantity::from("0.0001"))
656                .ts_event(UnixNanos::default())
657                .ts_init(UnixNanos::default())
658                .build()
659                .unwrap(),
660        )
661    }
662
663    fn stub_trade(is_maker_ask: bool) -> LighterTrade {
664        LighterTrade {
665            trade_id: 19209006902,
666            trade_id_str: Some("19209006902".to_string()),
667            tx_hash: "000000128b1ee814".to_string(),
668            trade_type: LighterTradeType::Trade,
669            market_id: 0,
670            size: Decimal::from_str("0.1336").unwrap(),
671            price: Decimal::from_str("2352.73").unwrap(),
672            usd_amount: Decimal::from_str("314.343").unwrap(),
673            ask_id: 281476929510102,
674            ask_id_str: Some("281476929510102".to_string()),
675            bid_id: 562947905631053,
676            bid_id_str: Some("562947905631053".to_string()),
677            ask_client_id: 0,
678            ask_client_id_str: Some("0".to_string()),
679            bid_client_id: 7001011966,
680            bid_client_id_str: Some("7001011966".to_string()),
681            ask_account_id: 91249,
682            bid_account_id: 281474976618239,
683            is_maker_ask,
684            block_height: 227535535,
685            timestamp: 1777941384181,
686            taker_fee: Some(238),
687            taker_position_size_before: Some(Decimal::from_str("-31.5754").unwrap()),
688            taker_entry_quote_before: Some(Decimal::from_str("72515.683629").unwrap()),
689            taker_initial_margin_fraction_before: Some(200),
690            taker_position_sign_changed: None,
691            maker_fee: Some(34),
692            maker_position_size_before: Some(Decimal::from_str("-1.4442").unwrap()),
693            maker_entry_quote_before: Some(Decimal::from_str("3399.343032").unwrap()),
694            maker_initial_margin_fraction_before: Some(500),
695            maker_position_sign_changed: None,
696            transaction_time: 1777941384181586,
697            ask_account_pnl: None,
698            bid_account_pnl: None,
699        }
700    }
701
702    fn stub_order_book(
703        symbol: &str,
704        market_id: i64,
705        market_type: LighterProductType,
706    ) -> LighterOrderBook {
707        LighterOrderBook {
708            symbol: Ustr::from(symbol),
709            market_id,
710            market_type,
711            base_asset_id: 0,
712            quote_asset_id: 0,
713            status: LighterMarketStatus::Active,
714            taker_fee: Decimal::ZERO,
715            maker_fee: Decimal::ZERO,
716            liquidation_fee: Decimal::from_str("1.0000").unwrap(),
717            min_base_amount: Decimal::from_str("0.0050").unwrap(),
718            min_quote_amount: Decimal::from_str("10.000000").unwrap(),
719            order_quote_limit: Decimal::from_str("281474976.710655").unwrap(),
720            supported_size_decimals: 4,
721            supported_price_decimals: 2,
722            supported_quote_decimals: 6,
723        }
724    }
725
726    fn stub_perp_detail(symbol: &str, market_id: i64) -> LighterPerpOrderBookDetail {
727        LighterPerpOrderBookDetail {
728            order_book: stub_order_book(symbol, market_id, LighterProductType::Perp),
729            size_decimals: 4,
730            price_decimals: 2,
731            quote_multiplier: 1,
732            default_initial_margin_fraction: 500,
733            min_initial_margin_fraction: 200,
734            maintenance_margin_fraction: 120,
735            closeout_margin_fraction: 80,
736            last_trade_price: Decimal::new(235_273, 2),
737            daily_trades_count: 0,
738            daily_base_token_volume: Decimal::ZERO,
739            daily_quote_token_volume: Decimal::ZERO,
740            daily_price_low: Decimal::ZERO,
741            daily_price_high: Decimal::ZERO,
742            daily_price_change: Decimal::ZERO,
743            open_interest: Decimal::ZERO,
744            daily_chart: Default::default(),
745            market_config: LighterMarketConfig {
746                market_margin_mode: LighterPositionMarginMode::Cross,
747                insurance_fund_account_index: 281474976710655,
748                liquidation_mode: 0,
749                force_reduce_only: false,
750                trading_hours: String::new(),
751                funding_fee_discounts_enabled: false,
752                hidden: false,
753            },
754            strategy_index: 2,
755        }
756    }
757
758    fn stub_spot_detail(symbol: &str, market_id: i64) -> LighterSpotOrderBookDetail {
759        LighterSpotOrderBookDetail {
760            order_book: stub_order_book(symbol, market_id, LighterProductType::Spot),
761            size_decimals: 6,
762            price_decimals: 6,
763            last_trade_price: Decimal::ONE,
764            daily_trades_count: 0,
765            daily_base_token_volume: Decimal::ZERO,
766            daily_quote_token_volume: Decimal::ZERO,
767            daily_price_low: Decimal::ZERO,
768            daily_price_high: Decimal::ZERO,
769            daily_price_change: Decimal::ZERO,
770            daily_chart: Default::default(),
771        }
772    }
773
774    #[rstest]
775    fn test_parse_trade_tick_maps_aggressor_from_maker_side() {
776        let instrument = create_test_instrument();
777        let ts_init = UnixNanos::from(1);
778
779        let seller = parse_trade_tick(&stub_trade(false), &instrument, ts_init).unwrap();
780        let buyer = parse_trade_tick(&stub_trade(true), &instrument, ts_init).unwrap();
781
782        assert_eq!(seller.aggressor_side, AggressorSide::Sell);
783        assert_eq!(buyer.aggressor_side, AggressorSide::Buy);
784        assert_eq!(seller.price, Price::from("2352.73"));
785        assert_eq!(seller.size, Quantity::from("0.1336"));
786        assert_eq!(seller.trade_id.to_string(), "19209006902");
787        assert_eq!(seller.ts_event, UnixNanos::from(1_777_941_384_181_000_000),);
788    }
789
790    #[rstest]
791    fn test_parse_trade_tick_uses_numeric_trade_id_when_string_missing() {
792        let instrument = create_test_instrument();
793        let mut trade = stub_trade(false);
794        trade.trade_id_str = None;
795
796        let tick = parse_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap();
797
798        assert_eq!(tick.trade_id.to_string(), "19209006902");
799    }
800
801    #[rstest]
802    fn test_parse_trade_tick_rejects_negative_timestamp() {
803        let instrument = create_test_instrument();
804        let mut trade = stub_trade(false);
805        trade.timestamp = -1;
806
807        let err = parse_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap_err();
808
809        assert!(err.to_string().contains("negative Lighter trade timestamp"));
810    }
811
812    #[rstest]
813    fn test_parse_trade_tick_propagates_invalid_price() {
814        // With the Decimal model field invalid wire data is rejected at JSON
815        // deserialize time, before parse_trade_tick ever runs. This guards
816        // that a malformed `price` in the venue payload surfaces a parse error
817        // rather than silently constructing a zero-priced tick.
818        let payload = serde_json::json!({
819            "trade_id": 1,
820            "tx_hash": "deadbeef",
821            "type": "trade",
822            "market_id": 0,
823            "size": "1.0",
824            "price": "not-a-price",
825            "usd_amount": "0",
826            "ask_id": 0,
827            "bid_id": 0,
828            "ask_client_id": 0,
829            "bid_client_id": 0,
830            "ask_account_id": 0,
831            "bid_account_id": 0,
832            "is_maker_ask": false,
833            "block_height": 0,
834            "timestamp": 1,
835            "transaction_time": 1,
836        });
837
838        let err = serde_json::from_value::<LighterTrade>(payload).unwrap_err();
839        assert!(err.to_string().to_lowercase().contains("decimal"));
840    }
841
842    #[rstest]
843    fn test_parse_funding_rate_update_maps_direction_to_signed_rate() {
844        let instrument = create_test_instrument();
845        let ts_init = UnixNanos::from(1);
846        let long_pays = LighterFunding {
847            timestamp: 1_778_702_400,
848            value: Decimal::ZERO,
849            rate: Decimal::new(12, 4),
850            direction: LighterFundingDirection::Long,
851        };
852        let short_pays = LighterFunding {
853            direction: LighterFundingDirection::Short,
854            ..long_pays
855        };
856
857        let positive =
858            parse_funding_rate_update(&long_pays, instrument.id(), Some(60), ts_init).unwrap();
859        let negative =
860            parse_funding_rate_update(&short_pays, instrument.id(), Some(60), ts_init).unwrap();
861
862        assert_eq!(positive.instrument_id, instrument.id());
863        assert_eq!(positive.rate, Decimal::new(12, 4));
864        assert_eq!(positive.interval, Some(60));
865        assert_eq!(
866            positive.ts_event,
867            UnixNanos::from(1_778_702_400_000_000_000)
868        );
869        assert_eq!(negative.rate, Decimal::new(-12, 4));
870    }
871
872    #[rstest]
873    fn test_parse_funding_rate_update_rejects_negative_timestamp() {
874        let instrument = create_test_instrument();
875        let funding = LighterFunding {
876            timestamp: -1,
877            value: Decimal::ZERO,
878            rate: Decimal::new(12, 4),
879            direction: LighterFundingDirection::Long,
880        };
881
882        let err = parse_funding_rate_update(&funding, instrument.id(), None, UnixNanos::from(1))
883            .unwrap_err();
884
885        assert!(
886            err.to_string()
887                .contains("negative Lighter funding timestamp")
888        );
889    }
890
891    fn test_bar_type(instrument_id: InstrumentId) -> BarType {
892        BarType::new(
893            instrument_id,
894            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
895            AggregationSource::External,
896        )
897    }
898
899    #[rstest]
900    fn test_parse_candle_bar_loads_fixture() {
901        let instrument = create_test_instrument();
902        let bar_type = test_bar_type(instrument.id());
903        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
904        let candle = &candles.candles[0];
905
906        let bar = parse_candle_bar(candle, bar_type, &instrument, UnixNanos::from(42)).unwrap();
907
908        assert_eq!(bar.bar_type, bar_type);
909        assert_eq!(bar.open, Price::from("2361.11"));
910        assert_eq!(bar.high, Price::from("2362.22"));
911        assert_eq!(bar.low, Price::from("2360.00"));
912        assert_eq!(bar.close, Price::from("2361.31"));
913        assert_eq!(bar.volume, Quantity::from("1.2345"));
914        assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
915        assert_eq!(bar.ts_init, UnixNanos::from(42));
916    }
917
918    #[rstest]
919    fn test_parse_candle_bar_rejects_negative_timestamp() {
920        let instrument = create_test_instrument();
921        let bar_type = test_bar_type(instrument.id());
922        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
923        let mut candle = candles.candles[0].clone();
924        candle.timestamp = -1;
925
926        let err = parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
927
928        assert!(
929            err.to_string()
930                .contains("negative Lighter candle timestamp")
931        );
932    }
933
934    #[rstest]
935    #[case("open", Decimal::ZERO)]
936    #[case("open", Decimal::NEGATIVE_ONE)]
937    #[case("high", Decimal::ZERO)]
938    #[case("high", Decimal::NEGATIVE_ONE)]
939    #[case("low", Decimal::ZERO)]
940    #[case("low", Decimal::NEGATIVE_ONE)]
941    #[case("close", Decimal::ZERO)]
942    #[case("close", Decimal::NEGATIVE_ONE)]
943    fn test_parse_candle_bar_rejects_non_positive_ohlc(
944        #[case] field: &str,
945        #[case] value: Decimal,
946    ) {
947        let instrument = create_test_instrument();
948        let bar_type = test_bar_type(instrument.id());
949        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
950        let mut candle = candles.candles[0].clone();
951        match field {
952            "open" => candle.open = value,
953            "high" => candle.high = value,
954            "low" => candle.low = value,
955            "close" => candle.close = value,
956            _ => unreachable!(),
957        }
958
959        let error =
960            parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
961
962        assert!(
963            error
964                .to_string()
965                .contains(&format!("non-positive candle {field}")),
966        );
967    }
968
969    // The previous string-roundtrip implementation rejected negative volume
970    // implicitly via `parse_quantity`'s decimal sign check. The current
971    // direct-Decimal implementation enforces the same constraint with an
972    // explicit `is_sign_positive` ensure-clause; without this test a future
973    // refactor could drop the guard silently and produce a malformed bar.
974    #[rstest]
975    fn test_parse_candle_bar_rejects_negative_volume() {
976        let instrument = create_test_instrument();
977        let bar_type = test_bar_type(instrument.id());
978        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
979        let mut candle = candles.candles[0].clone();
980        candle.volume_base = Decimal::new(-1, 0);
981
982        let err = parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
983
984        assert!(
985            err.to_string().contains("negative candle volume"),
986            "expected negative-volume error, was: {err}",
987        );
988    }
989
990    #[rstest]
991    fn test_parse_order_book_snapshot_includes_clear_and_last_flag() {
992        let instrument = create_test_instrument();
993        let snapshot = LighterOrderBookOrders {
994            code: 200,
995            message: None,
996            total_asks: 1,
997            asks: vec![LighterSimpleOrder {
998                order_index: 281476929510110,
999                order_id: "281476929510110".to_string(),
1000                owner_account_index: 712440,
1001                initial_base_amount: Decimal::from_str("0.0050").unwrap(),
1002                remaining_base_amount: Decimal::from_str("0.0050").unwrap(),
1003                price: Decimal::from_str("2352.74").unwrap(),
1004                order_expiry: 1780360584479,
1005                transaction_time: 0,
1006            }],
1007            total_bids: 1,
1008            bids: vec![LighterSimpleOrder {
1009                order_index: 562947905631047,
1010                order_id: "562947905631047".to_string(),
1011                owner_account_index: 281474976619400,
1012                initial_base_amount: Decimal::from_str("0.2125").unwrap(),
1013                remaining_base_amount: Decimal::from_str("0.2125").unwrap(),
1014                price: Decimal::from_str("2352.71").unwrap(),
1015                order_expiry: 1780360585134,
1016                transaction_time: 0,
1017            }],
1018        };
1019        let ts_event = UnixNanos::from(10);
1020        let ts_init = UnixNanos::from(20);
1021
1022        let deltas = parse_order_book_snapshot(
1023            &snapshot,
1024            instrument.id(),
1025            instrument.price_precision(),
1026            instrument.size_precision(),
1027            ts_event,
1028            ts_init,
1029        )
1030        .unwrap();
1031
1032        assert_eq!(deltas.deltas.len(), 3);
1033        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1034        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
1035        assert_eq!(deltas.deltas[1].order.price, Price::from("2352.71"));
1036        assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.2125"));
1037        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell.into());
1038        assert_eq!(deltas.deltas[2].order.price, Price::from("2352.74"));
1039        assert_eq!(deltas.deltas[2].order.size, Quantity::from("0.0050"));
1040        assert_eq!(deltas.deltas[0].sequence, 0);
1041        assert_eq!(deltas.deltas[1].sequence, 1);
1042        assert_eq!(deltas.deltas[2].sequence, 2);
1043        assert_eq!(deltas.sequence, 2);
1044        assert_eq!(
1045            deltas.deltas[2].flags & RecordFlag::F_LAST as u8,
1046            RecordFlag::F_LAST as u8
1047        );
1048    }
1049
1050    #[rstest]
1051    fn test_parse_order_book_snapshot_marks_empty_clear_as_last() {
1052        let instrument = create_test_instrument();
1053        let snapshot = LighterOrderBookOrders {
1054            code: 200,
1055            message: None,
1056            total_asks: 0,
1057            asks: vec![],
1058            total_bids: 0,
1059            bids: vec![],
1060        };
1061
1062        let deltas = parse_order_book_snapshot(
1063            &snapshot,
1064            instrument.id(),
1065            instrument.price_precision(),
1066            instrument.size_precision(),
1067            UnixNanos::from(10),
1068            UnixNanos::from(20),
1069        )
1070        .unwrap();
1071
1072        assert_eq!(deltas.deltas.len(), 1);
1073        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1074        assert_eq!(
1075            deltas.deltas[0].flags & RecordFlag::F_LAST as u8,
1076            RecordFlag::F_LAST as u8,
1077        );
1078    }
1079
1080    #[rstest]
1081    fn test_parse_order_book_snapshot_rejects_negative_order_index() {
1082        let instrument = create_test_instrument();
1083        let snapshot = LighterOrderBookOrders {
1084            code: 200,
1085            message: None,
1086            total_asks: 0,
1087            asks: vec![],
1088            total_bids: 1,
1089            bids: vec![LighterSimpleOrder {
1090                order_index: -1,
1091                order_id: "-1".to_string(),
1092                owner_account_index: 281474976619400,
1093                initial_base_amount: Decimal::from_str("0.2125").unwrap(),
1094                remaining_base_amount: Decimal::from_str("0.2125").unwrap(),
1095                price: Decimal::from_str("2352.71").unwrap(),
1096                order_expiry: 1780360585134,
1097                transaction_time: 0,
1098            }],
1099        };
1100
1101        let err = parse_order_book_snapshot(
1102            &snapshot,
1103            instrument.id(),
1104            instrument.price_precision(),
1105            instrument.size_precision(),
1106            UnixNanos::from(10),
1107            UnixNanos::from(20),
1108        )
1109        .unwrap_err();
1110
1111        assert!(err.to_string().contains("negative Lighter bid order index"));
1112    }
1113
1114    #[rstest]
1115    fn test_parse_order_book_snapshot_rejects_zero_size_level() {
1116        let instrument = create_test_instrument();
1117        let snapshot = LighterOrderBookOrders {
1118            code: 200,
1119            message: None,
1120            total_asks: 0,
1121            asks: vec![],
1122            total_bids: 1,
1123            bids: vec![LighterSimpleOrder {
1124                order_index: 562947905631047,
1125                order_id: "562947905631047".to_string(),
1126                owner_account_index: 281474976619400,
1127                initial_base_amount: Decimal::from_str("0.2125").unwrap(),
1128                remaining_base_amount: Decimal::ZERO,
1129                price: Decimal::from_str("2352.71").unwrap(),
1130                order_expiry: 1780360585134,
1131                transaction_time: 0,
1132            }],
1133        };
1134
1135        let err = parse_order_book_snapshot(
1136            &snapshot,
1137            instrument.id(),
1138            instrument.price_precision(),
1139            instrument.size_precision(),
1140            UnixNanos::from(10),
1141            UnixNanos::from(20),
1142        )
1143        .unwrap_err();
1144
1145        assert!(
1146            err.to_string()
1147                .contains("failed to construct Lighter bid snapshot delta")
1148        );
1149    }
1150
1151    fn stub_simple_order(price: &str, remaining_base_amount: &str) -> LighterSimpleOrder {
1152        let amount = Decimal::from_str(remaining_base_amount).unwrap();
1153        let price = Decimal::from_str(price).unwrap();
1154        LighterSimpleOrder {
1155            order_index: 0,
1156            order_id: "0".to_string(),
1157            owner_account_index: 0,
1158            initial_base_amount: amount,
1159            remaining_base_amount: amount,
1160            price,
1161            order_expiry: 0,
1162            transaction_time: 0,
1163        }
1164    }
1165
1166    #[rstest]
1167    fn test_parse_l2_order_book_snapshot_two_sided_aggregates_per_price() {
1168        let instrument = create_test_instrument();
1169        let snapshot = LighterOrderBookOrders {
1170            code: 200,
1171            message: None,
1172            total_asks: 3,
1173            asks: vec![
1174                stub_simple_order("2352.74", "0.0050"),
1175                stub_simple_order("2353.00", "1.0000"),
1176                stub_simple_order("2354.00", "2.0000"),
1177            ],
1178            total_bids: 2,
1179            // Two orders at the same bid price must aggregate into one level.
1180            bids: vec![
1181                stub_simple_order("2000.00", "0.0100"),
1182                stub_simple_order("2000.00", "0.0200"),
1183            ],
1184        };
1185
1186        let book = parse_l2_order_book_snapshot(
1187            &snapshot,
1188            instrument.id(),
1189            instrument.price_precision(),
1190            instrument.size_precision(),
1191        );
1192
1193        assert_eq!(book.book_type, BookType::L2_MBP);
1194        assert_eq!(book.instrument_id, instrument.id());
1195        assert_eq!(book.best_bid_price(), Some(Price::from("2000.00")));
1196        assert_eq!(book.best_bid_size(), Some(Quantity::from("0.0300")));
1197        assert_eq!(book.best_ask_price(), Some(Price::from("2352.74")));
1198        assert_eq!(book.best_ask_size(), Some(Quantity::from("0.0050")));
1199        // Snapshot must not stamp a wall-clock ts_event so the first WS delta
1200        // can install a real venue timestamp without tripping the model's
1201        // out-of-order guard.
1202        assert_eq!(book.ts_last, UnixNanos::default());
1203    }
1204
1205    #[rstest]
1206    fn test_parse_l2_order_book_snapshot_one_sided_only_populates_asks() {
1207        let instrument = create_test_instrument();
1208        let snapshot = LighterOrderBookOrders {
1209            code: 200,
1210            message: None,
1211            total_asks: 1,
1212            asks: vec![stub_simple_order("2352.74", "0.0050")],
1213            total_bids: 0,
1214            bids: vec![],
1215        };
1216
1217        let book = parse_l2_order_book_snapshot(
1218            &snapshot,
1219            instrument.id(),
1220            instrument.price_precision(),
1221            instrument.size_precision(),
1222        );
1223
1224        assert_eq!(book.best_bid_price(), None);
1225        assert_eq!(book.best_ask_price(), Some(Price::from("2352.74")));
1226        assert_eq!(book.best_ask_size(), Some(Quantity::from("0.0050")));
1227    }
1228
1229    #[rstest]
1230    fn test_parse_l2_order_book_snapshot_empty_book_yields_empty_book() {
1231        let instrument = create_test_instrument();
1232        let snapshot = LighterOrderBookOrders {
1233            code: 200,
1234            message: None,
1235            total_asks: 0,
1236            asks: vec![],
1237            total_bids: 0,
1238            bids: vec![],
1239        };
1240
1241        let book = parse_l2_order_book_snapshot(
1242            &snapshot,
1243            instrument.id(),
1244            instrument.price_precision(),
1245            instrument.size_precision(),
1246        );
1247
1248        assert_eq!(book.best_bid_price(), None);
1249        assert_eq!(book.best_ask_price(), None);
1250        assert_eq!(book.ts_last, UnixNanos::default());
1251    }
1252
1253    #[rstest]
1254    #[case::zero_size("0")]
1255    #[case::negative_size("-1.0")]
1256    fn test_parse_l2_order_book_snapshot_skips_unusable_orders(#[case] remaining: &str) {
1257        // The L2 snapshot aggregator drops orders whose size is zero or
1258        // negative (still constructable as `Decimal`) and keeps the remaining
1259        // valid orders; contrast with `parse_order_book_snapshot` (the deltas
1260        // variant) which errs. Non-numeric wire values are now rejected at
1261        // JSON deserialize time, so the whole `LighterOrderBookOrders`
1262        // payload errors before the aggregator runs. The Decimal model field
1263        // chose typed correctness over per-order resilience to garbage from
1264        // the venue.
1265        let instrument = create_test_instrument();
1266        let snapshot = LighterOrderBookOrders {
1267            code: 200,
1268            message: None,
1269            total_asks: 1,
1270            asks: vec![stub_simple_order("2400.00", "1.0000")],
1271            total_bids: 2,
1272            bids: vec![
1273                stub_simple_order("2000.00", remaining),
1274                stub_simple_order("1999.50", "0.5000"),
1275            ],
1276        };
1277
1278        let book = parse_l2_order_book_snapshot(
1279            &snapshot,
1280            instrument.id(),
1281            instrument.price_precision(),
1282            instrument.size_precision(),
1283        );
1284
1285        // Bad bid is dropped; the second valid bid still seeds the bid side.
1286        assert_eq!(book.best_bid_price(), Some(Price::from("1999.50")));
1287        assert_eq!(book.best_ask_price(), Some(Price::from("2400.00")));
1288    }
1289
1290    #[rstest]
1291    fn test_parse_l2_order_book_snapshot_loads_fixture() {
1292        // End-to-end against the venue fixture: 1 bid + 1 ask, cross-checks
1293        // that the two-sided shape exercised by the rest of the test suite
1294        // round-trips through the L2 snapshot parser.
1295        const HTTP_ORDER_BOOK_ORDERS: &str =
1296            include_str!("../../test_data/http_order_book_orders.json");
1297
1298        let instrument = create_test_instrument();
1299        let snapshot: LighterOrderBookOrders =
1300            serde_json::from_str(HTTP_ORDER_BOOK_ORDERS).expect("fixture deserializes");
1301
1302        let book = parse_l2_order_book_snapshot(
1303            &snapshot,
1304            instrument.id(),
1305            instrument.price_precision(),
1306            instrument.size_precision(),
1307        );
1308
1309        assert_eq!(book.best_bid_price(), Some(Price::from("2361.17")));
1310        assert_eq!(book.best_bid_size(), Some(Quantity::from("3.4125")));
1311        assert_eq!(book.best_ask_price(), Some(Price::from("2361.32")));
1312        assert_eq!(book.best_ask_size(), Some(Quantity::from("0.0317")));
1313    }
1314
1315    #[rstest]
1316    fn test_register_order_books_populates_market_registry() {
1317        let registry = MarketRegistry::new();
1318        let order_books = vec![stub_order_book("ETH", 0, LighterProductType::Perp)];
1319
1320        register_order_books(&registry, &order_books);
1321
1322        assert_eq!(registry.market_index(&instrument_id("ETH-PERP")), Some(0));
1323    }
1324
1325    #[rstest]
1326    fn test_register_perp_order_book_details_populates_market_registry() {
1327        let registry = MarketRegistry::new();
1328        let details = vec![LighterPerpOrderBookDetail {
1329            order_book: stub_order_book("ETH", 0, LighterProductType::Perp),
1330            size_decimals: 4,
1331            price_decimals: 2,
1332            quote_multiplier: 1,
1333            default_initial_margin_fraction: 500,
1334            min_initial_margin_fraction: 200,
1335            maintenance_margin_fraction: 120,
1336            closeout_margin_fraction: 80,
1337            last_trade_price: Decimal::new(235_273, 2),
1338            daily_trades_count: 0,
1339            daily_base_token_volume: Decimal::ZERO,
1340            daily_quote_token_volume: Decimal::ZERO,
1341            daily_price_low: Decimal::ZERO,
1342            daily_price_high: Decimal::ZERO,
1343            daily_price_change: Decimal::ZERO,
1344            open_interest: Decimal::ZERO,
1345            daily_chart: Default::default(),
1346            market_config: LighterMarketConfig {
1347                market_margin_mode: LighterPositionMarginMode::Cross,
1348                insurance_fund_account_index: 281474976710655,
1349                liquidation_mode: 0,
1350                force_reduce_only: false,
1351                trading_hours: String::new(),
1352                funding_fee_discounts_enabled: false,
1353                hidden: false,
1354            },
1355            strategy_index: 2,
1356        }];
1357
1358        register_perp_order_book_details(&registry, &details);
1359
1360        assert_eq!(registry.market_index(&instrument_id("ETH-PERP")), Some(0));
1361    }
1362
1363    #[rstest]
1364    fn test_parse_order_book_details_instruments_rejects_invalid_min_quantity() {
1365        // A negative `min_base_amount` is unreachable from the wire (the venue
1366        // never sends one) but the parser must still reject it rather than
1367        // panic inside `Quantity::from_decimal_dp`.
1368        let registry = MarketRegistry::new();
1369        let mut order_book = stub_order_book("ETH", 0, LighterProductType::Perp);
1370        order_book.min_base_amount = Decimal::from_str("-0.0050").unwrap();
1371        let details = vec![LighterPerpOrderBookDetail {
1372            order_book,
1373            size_decimals: 4,
1374            price_decimals: 2,
1375            quote_multiplier: 1,
1376            default_initial_margin_fraction: 500,
1377            min_initial_margin_fraction: 200,
1378            maintenance_margin_fraction: 120,
1379            closeout_margin_fraction: 80,
1380            last_trade_price: Decimal::new(235_273, 2),
1381            daily_trades_count: 0,
1382            daily_base_token_volume: Decimal::ZERO,
1383            daily_quote_token_volume: Decimal::ZERO,
1384            daily_price_low: Decimal::ZERO,
1385            daily_price_high: Decimal::ZERO,
1386            daily_price_change: Decimal::ZERO,
1387            open_interest: Decimal::ZERO,
1388            daily_chart: Default::default(),
1389            market_config: LighterMarketConfig {
1390                market_margin_mode: LighterPositionMarginMode::Cross,
1391                insurance_fund_account_index: 281474976710655,
1392                liquidation_mode: 0,
1393                force_reduce_only: false,
1394                trading_hours: String::new(),
1395                funding_fee_discounts_enabled: false,
1396                hidden: false,
1397            },
1398            strategy_index: 2,
1399        }];
1400
1401        let err =
1402            parse_order_book_details_instruments(&registry, &details, &[], UnixNanos::from(1))
1403                .unwrap_err();
1404
1405        assert!(err.to_string().contains("negative quantity"));
1406        assert!(registry.is_empty());
1407    }
1408
1409    #[rstest]
1410    fn test_parse_order_book_details_instruments_skips_invalid_row_without_registry_pollution() {
1411        let registry = MarketRegistry::new();
1412        let mut invalid = stub_perp_detail("ETH", 0);
1413        invalid.order_book.min_base_amount = Decimal::NEGATIVE_ONE;
1414        let valid = stub_perp_detail("BTC", 1);
1415
1416        let instruments = parse_order_book_details_instruments(
1417            &registry,
1418            &[invalid, valid],
1419            &[],
1420            UnixNanos::from(1),
1421        )
1422        .unwrap();
1423
1424        assert_eq!(instruments.len(), 1);
1425        assert_eq!(instruments[0].id(), instrument_id("BTC-PERP"));
1426        assert_eq!(registry.market_index(&instrument_id("BTC-PERP")), Some(1));
1427        assert_eq!(registry.market_index(&instrument_id("ETH-PERP")), None);
1428    }
1429
1430    #[rstest]
1431    fn test_parse_order_book_details_instruments_skips_invalid_spot_row() {
1432        let registry = MarketRegistry::new();
1433        let mut invalid = stub_spot_detail("ETH/USDC", 2048);
1434        invalid.order_book.min_base_amount = Decimal::NEGATIVE_ONE;
1435        let valid = stub_spot_detail("BTC/USDC", 2049);
1436
1437        let instruments = parse_order_book_details_instruments(
1438            &registry,
1439            &[],
1440            &[invalid, valid],
1441            UnixNanos::from(1),
1442        )
1443        .unwrap();
1444
1445        assert_eq!(instruments.len(), 1);
1446        assert_eq!(instruments[0].id(), instrument_id("BTC/USDC-SPOT"));
1447        assert_eq!(
1448            registry.market_index(&instrument_id("BTC/USDC-SPOT")),
1449            Some(2049),
1450        );
1451        assert_eq!(registry.market_index(&instrument_id("ETH/USDC-SPOT")), None);
1452    }
1453
1454    #[rstest]
1455    fn test_parse_order_book_details_instruments_parses_spot_pair() {
1456        let registry = MarketRegistry::new();
1457        let details = vec![stub_spot_detail("ETH/USDC", 2048)];
1458        let instrument_id = instrument_id("ETH/USDC-SPOT");
1459
1460        let instruments =
1461            parse_order_book_details_instruments(&registry, &[], &details, UnixNanos::from(1))
1462                .unwrap();
1463
1464        assert_eq!(instruments.len(), 1);
1465        assert_eq!(registry.market_index(&instrument_id), Some(2048));
1466
1467        match &instruments[0] {
1468            InstrumentAny::CurrencyPair(pair) => {
1469                assert_eq!(pair.id, instrument_id);
1470                assert_eq!(pair.raw_symbol.as_str(), "ETH/USDC");
1471                assert_eq!(pair.base_currency, Currency::from("ETH"));
1472                assert_eq!(pair.quote_currency, Currency::from("USDC"));
1473                assert_eq!(pair.price_precision, 6);
1474                assert_eq!(pair.size_precision, 6);
1475                assert_eq!(pair.price_increment, Price::from("0.000001"));
1476                assert_eq!(pair.size_increment, Quantity::from("0.000001"));
1477                assert_eq!(pair.min_quantity, Some(Quantity::from("0.005000")));
1478                assert_eq!(
1479                    pair.max_notional,
1480                    Some(Money::from("281474976.710655 USDC"))
1481                );
1482                assert_eq!(pair.min_notional, Some(Money::from("10.000000 USDC")));
1483            }
1484            other => panic!("expected currency pair, was {other:?}"),
1485        }
1486    }
1487
1488    #[rstest]
1489    fn test_parse_perp_uses_registry_venue_and_settlement_currency() {
1490        let venue = Venue::new("LIGHTER_ROBINHOOD");
1491        let settlement_currency = Currency::USDG();
1492        let registry =
1493            MarketRegistry::new_with_venue_and_settlement_currency(venue, settlement_currency);
1494
1495        let details = vec![stub_perp_detail("ETH", 0)];
1496
1497        let instruments =
1498            parse_order_book_details_instruments(&registry, &details, &[], UnixNanos::from(1))
1499                .unwrap();
1500
1501        assert_eq!(instruments.len(), 1);
1502        match &instruments[0] {
1503            InstrumentAny::CryptoPerpetual(perp) => {
1504                assert_eq!(perp.id.venue, venue);
1505                assert_eq!(perp.base_currency, Currency::from("ETH"));
1506                assert_eq!(perp.quote_currency, settlement_currency);
1507                assert_eq!(perp.settlement_currency, settlement_currency);
1508            }
1509            other => panic!("expected crypto perpetual, was {other:?}"),
1510        }
1511    }
1512
1513    #[rstest]
1514    #[case::missing_quote_separator("ETH")]
1515    #[case::missing_base("/USDC")]
1516    #[case::missing_quote("ETH/")]
1517    #[case::extra_component("ETH/USDC/EXTRA")]
1518    fn test_parse_order_book_details_instruments_rejects_invalid_spot_pair(#[case] symbol: &str) {
1519        let registry = MarketRegistry::new();
1520        let details = vec![stub_spot_detail(symbol, 2048)];
1521
1522        let error =
1523            parse_order_book_details_instruments(&registry, &[], &details, UnixNanos::from(1))
1524                .unwrap_err();
1525
1526        assert!(error.to_string().contains("BASE/QUOTE"));
1527        assert!(registry.is_empty());
1528    }
1529
1530    #[rstest]
1531    fn test_register_spot_order_book_details_populates_market_registry() {
1532        let registry = MarketRegistry::new();
1533        let details = vec![stub_spot_detail("ETH/USDC", 2048)];
1534
1535        register_spot_order_book_details(&registry, &details);
1536
1537        assert_eq!(
1538            registry.market_index(&instrument_id("ETH/USDC-SPOT")),
1539            Some(2048)
1540        );
1541    }
1542
1543    fn instrument_id(symbol: &str) -> InstrumentId {
1544        InstrumentId::new(Symbol::new(symbol), Venue::new("LIGHTER"))
1545    }
1546
1547    #[rstest]
1548    fn test_parse_order_book_details_instruments_routes_widened_ids_by_market_type() {
1549        let registry = MarketRegistry::new();
1550        let details: LighterOrderBookDetails =
1551            serde_json::from_str(HTTP_ORDER_BOOK_DETAILS_WIDENED_IDS).unwrap();
1552
1553        let instruments = parse_order_book_details_instruments(
1554            &registry,
1555            &details.order_book_details,
1556            &details.spot_order_book_details,
1557            UnixNanos::from(1),
1558        )
1559        .unwrap();
1560
1561        assert_eq!(instruments.len(), 4);
1562
1563        let eth_perp = instrument_id("ETH-PERP");
1564        let future_perp = instrument_id("FUTURE-PERP");
1565        let eth_spot = instrument_id("ETH/USDC-SPOT");
1566        let future_spot = instrument_id("FUTURE/USDC-SPOT");
1567
1568        assert_eq!(registry.market_index(&eth_perp), Some(4095));
1569        assert_eq!(registry.market_index(&future_perp), Some(40_000));
1570        assert_eq!(registry.market_index(&eth_spot), Some(4098));
1571        assert_eq!(registry.market_index(&future_spot), Some(50_000));
1572
1573        let mut kinds: Vec<(&str, &str)> = instruments
1574            .iter()
1575            .map(|instrument| match instrument {
1576                InstrumentAny::CryptoPerpetual(perp) => (perp.id.symbol.as_str(), "perp"),
1577                InstrumentAny::CurrencyPair(pair) => (pair.id.symbol.as_str(), "spot"),
1578                other => panic!("unexpected instrument kind, was {other:?}"),
1579            })
1580            .collect();
1581
1582        kinds.sort_unstable();
1583        assert_eq!(
1584            kinds,
1585            vec![
1586                ("ETH-PERP", "perp"),
1587                ("ETH/USDC-SPOT", "spot"),
1588                ("FUTURE-PERP", "perp"),
1589                ("FUTURE/USDC-SPOT", "spot"),
1590            ],
1591        );
1592    }
1593}