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        .maker_fee(order_book.maker_fee)
485        .taker_fee(order_book.taker_fee)
486        .ts_event(ts_init)
487        .ts_init(ts_init)
488        .build()
489        .map_err(|e| anyhow::anyhow!("{e}"))?;
490
491    registry.insert(
492        order_book.market_id,
493        order_book.symbol.as_str(),
494        order_book.market_type,
495    );
496
497    Ok(InstrumentAny::CryptoPerpetual(instrument))
498}
499
500fn parse_spot_instrument(
501    registry: &MarketRegistry,
502    detail: &LighterSpotOrderBookDetail,
503    ts_init: UnixNanos,
504) -> anyhow::Result<InstrumentAny> {
505    let order_book = &detail.order_book;
506
507    let instrument_id = format_instrument_id_with_venue(
508        order_book.symbol.as_str(),
509        order_book.market_type,
510        registry.venue(),
511    );
512
513    let raw_symbol = Symbol::from_ustr_unchecked(order_book.symbol);
514    let (base_currency, quote_currency) = spot_symbol_currencies(order_book.symbol.as_str())?;
515    let price_increment = price_increment(detail.price_decimals)?;
516    let size_increment = quantity_increment(detail.size_decimals)?;
517
518    let instrument = CurrencyPair::builder()
519        .instrument_id(instrument_id)
520        .raw_symbol(raw_symbol)
521        .base_currency(base_currency)
522        .quote_currency(quote_currency)
523        .price_precision(detail.price_decimals)
524        .size_precision(detail.size_decimals)
525        .price_increment(price_increment)
526        .size_increment(size_increment)
527        .maybe_min_quantity(min_quantity(order_book, detail.size_decimals)?)
528        .maybe_max_notional(max_notional(order_book, quote_currency)?)
529        .maybe_min_notional(min_notional(order_book, quote_currency)?)
530        .maker_fee(order_book.maker_fee)
531        .taker_fee(order_book.taker_fee)
532        .ts_event(ts_init)
533        .ts_init(ts_init)
534        .build()
535        .map_err(|e| anyhow::anyhow!("{e}"))?;
536
537    registry.insert(
538        order_book.market_id,
539        order_book.symbol.as_str(),
540        order_book.market_type,
541    );
542
543    Ok(InstrumentAny::CurrencyPair(instrument))
544}
545
546fn spot_symbol_currencies(symbol: &str) -> anyhow::Result<(Currency, Currency)> {
547    let (base, quote) = symbol
548        .split_once('/')
549        .context("Lighter spot symbol must use BASE/QUOTE format")?;
550    let base = base.trim();
551    let quote = quote.trim();
552    anyhow::ensure!(
553        !base.is_empty() && !quote.is_empty() && !quote.contains('/'),
554        "Lighter spot symbol must contain one nonempty BASE/QUOTE pair",
555    );
556
557    Ok((
558        Currency::get_or_create_crypto(base),
559        Currency::get_or_create_crypto(quote),
560    ))
561}
562
563fn symbol_currencies(symbol: &str, default_quote: &str) -> (Currency, Currency) {
564    let (base, quote) = symbol.split_once('/').unwrap_or((symbol, default_quote));
565    (
566        Currency::get_or_create_crypto(base),
567        Currency::get_or_create_crypto(quote),
568    )
569}
570
571fn price_increment(decimals: u8) -> anyhow::Result<Price> {
572    Price::from_decimal_dp(decimal_increment(decimals), decimals)
573        .map_err(|e| anyhow::anyhow!("{e}"))
574}
575
576fn quantity_increment(decimals: u8) -> anyhow::Result<Quantity> {
577    Quantity::from_decimal_dp(decimal_increment(decimals), decimals)
578        .map_err(|e| anyhow::anyhow!("{e}"))
579}
580
581// `10^-decimals` as an exact decimal (e.g. 3 -> 0.001, 0 -> 1).
582fn decimal_increment(decimals: u8) -> Decimal {
583    Decimal::new(1, u32::from(decimals))
584}
585
586fn min_quantity(
587    order_book: &LighterOrderBook,
588    size_decimals: u8,
589) -> anyhow::Result<Option<Quantity>> {
590    quantity_from_decimal(order_book.min_base_amount, size_decimals).map(Some)
591}
592
593fn min_notional(
594    order_book: &LighterOrderBook,
595    currency: Currency,
596) -> anyhow::Result<Option<Money>> {
597    money_from_decimal(order_book.min_quote_amount, currency).map(Some)
598}
599
600fn max_notional(
601    order_book: &LighterOrderBook,
602    currency: Currency,
603) -> anyhow::Result<Option<Money>> {
604    money_from_decimal(order_book.order_quote_limit, currency).map(Some)
605}
606
607fn money_from_decimal(value: Decimal, currency: Currency) -> anyhow::Result<Money> {
608    Money::from_decimal(value, currency).map_err(|e| anyhow::anyhow!("{e}"))
609}
610
611fn margin_fraction(value: u16) -> Decimal {
612    Decimal::from(value) / Decimal::from(10_000)
613}
614
615#[cfg(test)]
616mod tests {
617    use std::str::FromStr;
618
619    use nautilus_model::{
620        data::{BarSpecification, BarType},
621        enums::{AggregationSource, BarAggregation, PriceType},
622        identifiers::{InstrumentId, Symbol, Venue},
623        instruments::CryptoPerpetual,
624        types::{Money, Price, Quantity, currency::Currency},
625    };
626    use rstest::rstest;
627    use rust_decimal::Decimal;
628    use ustr::Ustr;
629
630    use super::*;
631    use crate::{
632        common::enums::{
633            LighterMarketStatus, LighterPositionMarginMode, LighterProductType, LighterTradeType,
634        },
635        http::models::{
636            LighterCandles, LighterFunding, LighterFundingDirection, LighterMarketConfig,
637            LighterOrderBookDetails, LighterSimpleOrder,
638        },
639    };
640
641    const HTTP_CANDLES: &str = include_str!("../../test_data/http_candles.json");
642    const HTTP_ORDER_BOOK_DETAILS_WIDENED_IDS: &str =
643        include_str!("../../test_data/http_order_book_details_widened_ids.json");
644
645    fn create_test_instrument() -> InstrumentAny {
646        let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
647
648        InstrumentAny::CryptoPerpetual(
649            CryptoPerpetual::builder()
650                .instrument_id(instrument_id)
651                .raw_symbol(Symbol::new("ETH-PERP"))
652                .base_currency(Currency::from("ETH"))
653                .quote_currency(Currency::from("USDC"))
654                .settlement_currency(Currency::from("USDC"))
655                .is_inverse(false)
656                .price_precision(2)
657                .size_precision(4)
658                .price_increment(Price::from("0.01"))
659                .size_increment(Quantity::from("0.0001"))
660                .ts_event(UnixNanos::default())
661                .ts_init(UnixNanos::default())
662                .build()
663                .unwrap(),
664        )
665    }
666
667    fn stub_trade(is_maker_ask: bool) -> LighterTrade {
668        LighterTrade {
669            trade_id: 19209006902,
670            trade_id_str: Some("19209006902".to_string()),
671            tx_hash: "000000128b1ee814".to_string(),
672            trade_type: LighterTradeType::Trade,
673            market_id: 0,
674            size: Decimal::from_str("0.1336").unwrap(),
675            price: Decimal::from_str("2352.73").unwrap(),
676            usd_amount: Decimal::from_str("314.343").unwrap(),
677            ask_id: 281476929510102,
678            ask_id_str: Some("281476929510102".to_string()),
679            bid_id: 562947905631053,
680            bid_id_str: Some("562947905631053".to_string()),
681            ask_client_id: 0,
682            ask_client_id_str: Some("0".to_string()),
683            bid_client_id: 7001011966,
684            bid_client_id_str: Some("7001011966".to_string()),
685            ask_account_id: 91249,
686            bid_account_id: 281474976618239,
687            is_maker_ask,
688            block_height: 227535535,
689            timestamp: 1777941384181,
690            taker_fee: Some(238),
691            taker_position_size_before: Some(Decimal::from_str("-31.5754").unwrap()),
692            taker_entry_quote_before: Some(Decimal::from_str("72515.683629").unwrap()),
693            taker_initial_margin_fraction_before: Some(200),
694            taker_position_sign_changed: None,
695            maker_fee: Some(34),
696            maker_position_size_before: Some(Decimal::from_str("-1.4442").unwrap()),
697            maker_entry_quote_before: Some(Decimal::from_str("3399.343032").unwrap()),
698            maker_initial_margin_fraction_before: Some(500),
699            maker_position_sign_changed: None,
700            transaction_time: 1777941384181586,
701            ask_account_pnl: None,
702            bid_account_pnl: None,
703        }
704    }
705
706    fn stub_order_book(
707        symbol: &str,
708        market_id: i64,
709        market_type: LighterProductType,
710    ) -> LighterOrderBook {
711        LighterOrderBook {
712            symbol: Ustr::from(symbol),
713            market_id,
714            market_type,
715            base_asset_id: 0,
716            quote_asset_id: 0,
717            status: LighterMarketStatus::Active,
718            taker_fee: Decimal::ZERO,
719            maker_fee: Decimal::ZERO,
720            liquidation_fee: Decimal::from_str("1.0000").unwrap(),
721            min_base_amount: Decimal::from_str("0.0050").unwrap(),
722            min_quote_amount: Decimal::from_str("10.000000").unwrap(),
723            order_quote_limit: Decimal::from_str("281474976.710655").unwrap(),
724            supported_size_decimals: 4,
725            supported_price_decimals: 2,
726            supported_quote_decimals: 6,
727        }
728    }
729
730    fn stub_perp_detail(symbol: &str, market_id: i64) -> LighterPerpOrderBookDetail {
731        LighterPerpOrderBookDetail {
732            order_book: stub_order_book(symbol, market_id, LighterProductType::Perp),
733            size_decimals: 4,
734            price_decimals: 2,
735            quote_multiplier: 1,
736            default_initial_margin_fraction: 500,
737            min_initial_margin_fraction: 200,
738            maintenance_margin_fraction: 120,
739            closeout_margin_fraction: 80,
740            last_trade_price: Decimal::new(235_273, 2),
741            daily_trades_count: 0,
742            daily_base_token_volume: Decimal::ZERO,
743            daily_quote_token_volume: Decimal::ZERO,
744            daily_price_low: Decimal::ZERO,
745            daily_price_high: Decimal::ZERO,
746            daily_price_change: Decimal::ZERO,
747            open_interest: Decimal::ZERO,
748            daily_chart: Default::default(),
749            market_config: LighterMarketConfig {
750                market_margin_mode: LighterPositionMarginMode::Cross,
751                insurance_fund_account_index: 281474976710655,
752                liquidation_mode: 0,
753                force_reduce_only: false,
754                trading_hours: String::new(),
755                funding_fee_discounts_enabled: false,
756                hidden: false,
757            },
758            strategy_index: 2,
759        }
760    }
761
762    fn stub_spot_detail(symbol: &str, market_id: i64) -> LighterSpotOrderBookDetail {
763        LighterSpotOrderBookDetail {
764            order_book: stub_order_book(symbol, market_id, LighterProductType::Spot),
765            size_decimals: 6,
766            price_decimals: 6,
767            last_trade_price: Decimal::ONE,
768            daily_trades_count: 0,
769            daily_base_token_volume: Decimal::ZERO,
770            daily_quote_token_volume: Decimal::ZERO,
771            daily_price_low: Decimal::ZERO,
772            daily_price_high: Decimal::ZERO,
773            daily_price_change: Decimal::ZERO,
774            daily_chart: Default::default(),
775        }
776    }
777
778    #[rstest]
779    fn test_parse_trade_tick_maps_aggressor_from_maker_side() {
780        let instrument = create_test_instrument();
781        let ts_init = UnixNanos::from(1);
782
783        let seller = parse_trade_tick(&stub_trade(false), &instrument, ts_init).unwrap();
784        let buyer = parse_trade_tick(&stub_trade(true), &instrument, ts_init).unwrap();
785
786        assert_eq!(seller.aggressor_side, AggressorSide::Sell);
787        assert_eq!(buyer.aggressor_side, AggressorSide::Buy);
788        assert_eq!(seller.price, Price::from("2352.73"));
789        assert_eq!(seller.size, Quantity::from("0.1336"));
790        assert_eq!(seller.trade_id.to_string(), "19209006902");
791        assert_eq!(seller.ts_event, UnixNanos::from(1_777_941_384_181_000_000),);
792    }
793
794    #[rstest]
795    fn test_parse_trade_tick_uses_numeric_trade_id_when_string_missing() {
796        let instrument = create_test_instrument();
797        let mut trade = stub_trade(false);
798        trade.trade_id_str = None;
799
800        let tick = parse_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap();
801
802        assert_eq!(tick.trade_id.to_string(), "19209006902");
803    }
804
805    #[rstest]
806    fn test_parse_trade_tick_rejects_negative_timestamp() {
807        let instrument = create_test_instrument();
808        let mut trade = stub_trade(false);
809        trade.timestamp = -1;
810
811        let err = parse_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap_err();
812
813        assert!(err.to_string().contains("negative Lighter trade timestamp"));
814    }
815
816    #[rstest]
817    fn test_parse_trade_tick_propagates_invalid_price() {
818        // With the Decimal model field invalid wire data is rejected at JSON
819        // deserialize time, before parse_trade_tick ever runs. This guards
820        // that a malformed `price` in the venue payload surfaces a parse error
821        // rather than silently constructing a zero-priced tick.
822        let payload = serde_json::json!({
823            "trade_id": 1,
824            "tx_hash": "deadbeef",
825            "type": "trade",
826            "market_id": 0,
827            "size": "1.0",
828            "price": "not-a-price",
829            "usd_amount": "0",
830            "ask_id": 0,
831            "bid_id": 0,
832            "ask_client_id": 0,
833            "bid_client_id": 0,
834            "ask_account_id": 0,
835            "bid_account_id": 0,
836            "is_maker_ask": false,
837            "block_height": 0,
838            "timestamp": 1,
839            "transaction_time": 1,
840        });
841
842        let err = serde_json::from_value::<LighterTrade>(payload).unwrap_err();
843        assert!(err.to_string().to_lowercase().contains("decimal"));
844    }
845
846    #[rstest]
847    fn test_parse_funding_rate_update_maps_direction_to_signed_rate() {
848        let instrument = create_test_instrument();
849        let ts_init = UnixNanos::from(1);
850        let long_pays = LighterFunding {
851            timestamp: 1_778_702_400,
852            value: Decimal::ZERO,
853            rate: Decimal::new(12, 4),
854            direction: LighterFundingDirection::Long,
855        };
856        let short_pays = LighterFunding {
857            direction: LighterFundingDirection::Short,
858            ..long_pays
859        };
860
861        let positive =
862            parse_funding_rate_update(&long_pays, instrument.id(), Some(60), ts_init).unwrap();
863        let negative =
864            parse_funding_rate_update(&short_pays, instrument.id(), Some(60), ts_init).unwrap();
865
866        assert_eq!(positive.instrument_id, instrument.id());
867        assert_eq!(positive.rate, Decimal::new(12, 4));
868        assert_eq!(positive.interval, Some(60));
869        assert_eq!(
870            positive.ts_event,
871            UnixNanos::from(1_778_702_400_000_000_000)
872        );
873        assert_eq!(negative.rate, Decimal::new(-12, 4));
874    }
875
876    #[rstest]
877    fn test_parse_funding_rate_update_rejects_negative_timestamp() {
878        let instrument = create_test_instrument();
879        let funding = LighterFunding {
880            timestamp: -1,
881            value: Decimal::ZERO,
882            rate: Decimal::new(12, 4),
883            direction: LighterFundingDirection::Long,
884        };
885
886        let err = parse_funding_rate_update(&funding, instrument.id(), None, UnixNanos::from(1))
887            .unwrap_err();
888
889        assert!(
890            err.to_string()
891                .contains("negative Lighter funding timestamp")
892        );
893    }
894
895    fn test_bar_type(instrument_id: InstrumentId) -> BarType {
896        BarType::new(
897            instrument_id,
898            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
899            AggregationSource::External,
900        )
901    }
902
903    #[rstest]
904    fn test_parse_candle_bar_loads_fixture() {
905        let instrument = create_test_instrument();
906        let bar_type = test_bar_type(instrument.id());
907        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
908        let candle = &candles.candles[0];
909
910        let bar = parse_candle_bar(candle, bar_type, &instrument, UnixNanos::from(42)).unwrap();
911
912        assert_eq!(bar.bar_type, bar_type);
913        assert_eq!(bar.open, Price::from("2361.11"));
914        assert_eq!(bar.high, Price::from("2362.22"));
915        assert_eq!(bar.low, Price::from("2360.00"));
916        assert_eq!(bar.close, Price::from("2361.31"));
917        assert_eq!(bar.volume, Quantity::from("1.2345"));
918        assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
919        assert_eq!(bar.ts_init, UnixNanos::from(42));
920    }
921
922    #[rstest]
923    fn test_parse_candle_bar_rejects_negative_timestamp() {
924        let instrument = create_test_instrument();
925        let bar_type = test_bar_type(instrument.id());
926        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
927        let mut candle = candles.candles[0].clone();
928        candle.timestamp = -1;
929
930        let err = parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
931
932        assert!(
933            err.to_string()
934                .contains("negative Lighter candle timestamp")
935        );
936    }
937
938    #[rstest]
939    #[case("open", Decimal::ZERO)]
940    #[case("open", Decimal::NEGATIVE_ONE)]
941    #[case("high", Decimal::ZERO)]
942    #[case("high", Decimal::NEGATIVE_ONE)]
943    #[case("low", Decimal::ZERO)]
944    #[case("low", Decimal::NEGATIVE_ONE)]
945    #[case("close", Decimal::ZERO)]
946    #[case("close", Decimal::NEGATIVE_ONE)]
947    fn test_parse_candle_bar_rejects_non_positive_ohlc(
948        #[case] field: &str,
949        #[case] value: Decimal,
950    ) {
951        let instrument = create_test_instrument();
952        let bar_type = test_bar_type(instrument.id());
953        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
954        let mut candle = candles.candles[0].clone();
955        match field {
956            "open" => candle.open = value,
957            "high" => candle.high = value,
958            "low" => candle.low = value,
959            "close" => candle.close = value,
960            _ => unreachable!(),
961        }
962
963        let error =
964            parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
965
966        assert!(
967            error
968                .to_string()
969                .contains(&format!("non-positive candle {field}")),
970        );
971    }
972
973    // The previous string-roundtrip implementation rejected negative volume
974    // implicitly via `parse_quantity`'s decimal sign check. The current
975    // direct-Decimal implementation enforces the same constraint with an
976    // explicit `is_sign_positive` ensure-clause; without this test a future
977    // refactor could drop the guard silently and produce a malformed bar.
978    #[rstest]
979    fn test_parse_candle_bar_rejects_negative_volume() {
980        let instrument = create_test_instrument();
981        let bar_type = test_bar_type(instrument.id());
982        let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
983        let mut candle = candles.candles[0].clone();
984        candle.volume_base = Decimal::new(-1, 0);
985
986        let err = parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
987
988        assert!(
989            err.to_string().contains("negative candle volume"),
990            "expected negative-volume error, was: {err}",
991        );
992    }
993
994    #[rstest]
995    fn test_parse_order_book_snapshot_includes_clear_and_last_flag() {
996        let instrument = create_test_instrument();
997        let snapshot = LighterOrderBookOrders {
998            code: 200,
999            message: None,
1000            total_asks: 1,
1001            asks: vec![LighterSimpleOrder {
1002                order_index: 281476929510110,
1003                order_id: "281476929510110".to_string(),
1004                owner_account_index: 712440,
1005                initial_base_amount: Decimal::from_str("0.0050").unwrap(),
1006                remaining_base_amount: Decimal::from_str("0.0050").unwrap(),
1007                price: Decimal::from_str("2352.74").unwrap(),
1008                order_expiry: 1780360584479,
1009                transaction_time: 0,
1010            }],
1011            total_bids: 1,
1012            bids: vec![LighterSimpleOrder {
1013                order_index: 562947905631047,
1014                order_id: "562947905631047".to_string(),
1015                owner_account_index: 281474976619400,
1016                initial_base_amount: Decimal::from_str("0.2125").unwrap(),
1017                remaining_base_amount: Decimal::from_str("0.2125").unwrap(),
1018                price: Decimal::from_str("2352.71").unwrap(),
1019                order_expiry: 1780360585134,
1020                transaction_time: 0,
1021            }],
1022        };
1023        let ts_event = UnixNanos::from(10);
1024        let ts_init = UnixNanos::from(20);
1025
1026        let deltas = parse_order_book_snapshot(
1027            &snapshot,
1028            instrument.id(),
1029            instrument.price_precision(),
1030            instrument.size_precision(),
1031            ts_event,
1032            ts_init,
1033        )
1034        .unwrap();
1035
1036        assert_eq!(deltas.deltas.len(), 3);
1037        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1038        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
1039        assert_eq!(deltas.deltas[1].order.price, Price::from("2352.71"));
1040        assert_eq!(deltas.deltas[1].order.size, Quantity::from("0.2125"));
1041        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell.into());
1042        assert_eq!(deltas.deltas[2].order.price, Price::from("2352.74"));
1043        assert_eq!(deltas.deltas[2].order.size, Quantity::from("0.0050"));
1044        assert_eq!(deltas.deltas[0].sequence, 0);
1045        assert_eq!(deltas.deltas[1].sequence, 1);
1046        assert_eq!(deltas.deltas[2].sequence, 2);
1047        assert_eq!(deltas.sequence, 2);
1048        assert_eq!(
1049            deltas.deltas[2].flags & RecordFlag::F_LAST as u8,
1050            RecordFlag::F_LAST as u8
1051        );
1052    }
1053
1054    #[rstest]
1055    fn test_parse_order_book_snapshot_marks_empty_clear_as_last() {
1056        let instrument = create_test_instrument();
1057        let snapshot = LighterOrderBookOrders {
1058            code: 200,
1059            message: None,
1060            total_asks: 0,
1061            asks: vec![],
1062            total_bids: 0,
1063            bids: vec![],
1064        };
1065
1066        let deltas = parse_order_book_snapshot(
1067            &snapshot,
1068            instrument.id(),
1069            instrument.price_precision(),
1070            instrument.size_precision(),
1071            UnixNanos::from(10),
1072            UnixNanos::from(20),
1073        )
1074        .unwrap();
1075
1076        assert_eq!(deltas.deltas.len(), 1);
1077        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1078        assert_eq!(
1079            deltas.deltas[0].flags & RecordFlag::F_LAST as u8,
1080            RecordFlag::F_LAST as u8,
1081        );
1082    }
1083
1084    #[rstest]
1085    fn test_parse_order_book_snapshot_rejects_negative_order_index() {
1086        let instrument = create_test_instrument();
1087        let snapshot = LighterOrderBookOrders {
1088            code: 200,
1089            message: None,
1090            total_asks: 0,
1091            asks: vec![],
1092            total_bids: 1,
1093            bids: vec![LighterSimpleOrder {
1094                order_index: -1,
1095                order_id: "-1".to_string(),
1096                owner_account_index: 281474976619400,
1097                initial_base_amount: Decimal::from_str("0.2125").unwrap(),
1098                remaining_base_amount: Decimal::from_str("0.2125").unwrap(),
1099                price: Decimal::from_str("2352.71").unwrap(),
1100                order_expiry: 1780360585134,
1101                transaction_time: 0,
1102            }],
1103        };
1104
1105        let err = parse_order_book_snapshot(
1106            &snapshot,
1107            instrument.id(),
1108            instrument.price_precision(),
1109            instrument.size_precision(),
1110            UnixNanos::from(10),
1111            UnixNanos::from(20),
1112        )
1113        .unwrap_err();
1114
1115        assert!(err.to_string().contains("negative Lighter bid order index"));
1116    }
1117
1118    #[rstest]
1119    fn test_parse_order_book_snapshot_rejects_zero_size_level() {
1120        let instrument = create_test_instrument();
1121        let snapshot = LighterOrderBookOrders {
1122            code: 200,
1123            message: None,
1124            total_asks: 0,
1125            asks: vec![],
1126            total_bids: 1,
1127            bids: vec![LighterSimpleOrder {
1128                order_index: 562947905631047,
1129                order_id: "562947905631047".to_string(),
1130                owner_account_index: 281474976619400,
1131                initial_base_amount: Decimal::from_str("0.2125").unwrap(),
1132                remaining_base_amount: Decimal::ZERO,
1133                price: Decimal::from_str("2352.71").unwrap(),
1134                order_expiry: 1780360585134,
1135                transaction_time: 0,
1136            }],
1137        };
1138
1139        let err = parse_order_book_snapshot(
1140            &snapshot,
1141            instrument.id(),
1142            instrument.price_precision(),
1143            instrument.size_precision(),
1144            UnixNanos::from(10),
1145            UnixNanos::from(20),
1146        )
1147        .unwrap_err();
1148
1149        assert!(
1150            err.to_string()
1151                .contains("failed to construct Lighter bid snapshot delta")
1152        );
1153    }
1154
1155    fn stub_simple_order(price: &str, remaining_base_amount: &str) -> LighterSimpleOrder {
1156        let amount = Decimal::from_str(remaining_base_amount).unwrap();
1157        let price = Decimal::from_str(price).unwrap();
1158        LighterSimpleOrder {
1159            order_index: 0,
1160            order_id: "0".to_string(),
1161            owner_account_index: 0,
1162            initial_base_amount: amount,
1163            remaining_base_amount: amount,
1164            price,
1165            order_expiry: 0,
1166            transaction_time: 0,
1167        }
1168    }
1169
1170    #[rstest]
1171    fn test_parse_l2_order_book_snapshot_two_sided_aggregates_per_price() {
1172        let instrument = create_test_instrument();
1173        let snapshot = LighterOrderBookOrders {
1174            code: 200,
1175            message: None,
1176            total_asks: 3,
1177            asks: vec![
1178                stub_simple_order("2352.74", "0.0050"),
1179                stub_simple_order("2353.00", "1.0000"),
1180                stub_simple_order("2354.00", "2.0000"),
1181            ],
1182            total_bids: 2,
1183            // Two orders at the same bid price must aggregate into one level.
1184            bids: vec![
1185                stub_simple_order("2000.00", "0.0100"),
1186                stub_simple_order("2000.00", "0.0200"),
1187            ],
1188        };
1189
1190        let book = parse_l2_order_book_snapshot(
1191            &snapshot,
1192            instrument.id(),
1193            instrument.price_precision(),
1194            instrument.size_precision(),
1195        );
1196
1197        assert_eq!(book.book_type, BookType::L2_MBP);
1198        assert_eq!(book.instrument_id, instrument.id());
1199        assert_eq!(book.best_bid_price(), Some(Price::from("2000.00")));
1200        assert_eq!(book.best_bid_size(), Some(Quantity::from("0.0300")));
1201        assert_eq!(book.best_ask_price(), Some(Price::from("2352.74")));
1202        assert_eq!(book.best_ask_size(), Some(Quantity::from("0.0050")));
1203        // Snapshot must not stamp a wall-clock ts_event so the first WS delta
1204        // can install a real venue timestamp without tripping the model's
1205        // out-of-order guard.
1206        assert_eq!(book.ts_last, UnixNanos::default());
1207    }
1208
1209    #[rstest]
1210    fn test_parse_l2_order_book_snapshot_one_sided_only_populates_asks() {
1211        let instrument = create_test_instrument();
1212        let snapshot = LighterOrderBookOrders {
1213            code: 200,
1214            message: None,
1215            total_asks: 1,
1216            asks: vec![stub_simple_order("2352.74", "0.0050")],
1217            total_bids: 0,
1218            bids: vec![],
1219        };
1220
1221        let book = parse_l2_order_book_snapshot(
1222            &snapshot,
1223            instrument.id(),
1224            instrument.price_precision(),
1225            instrument.size_precision(),
1226        );
1227
1228        assert_eq!(book.best_bid_price(), None);
1229        assert_eq!(book.best_ask_price(), Some(Price::from("2352.74")));
1230        assert_eq!(book.best_ask_size(), Some(Quantity::from("0.0050")));
1231    }
1232
1233    #[rstest]
1234    fn test_parse_l2_order_book_snapshot_empty_book_yields_empty_book() {
1235        let instrument = create_test_instrument();
1236        let snapshot = LighterOrderBookOrders {
1237            code: 200,
1238            message: None,
1239            total_asks: 0,
1240            asks: vec![],
1241            total_bids: 0,
1242            bids: vec![],
1243        };
1244
1245        let book = parse_l2_order_book_snapshot(
1246            &snapshot,
1247            instrument.id(),
1248            instrument.price_precision(),
1249            instrument.size_precision(),
1250        );
1251
1252        assert_eq!(book.best_bid_price(), None);
1253        assert_eq!(book.best_ask_price(), None);
1254        assert_eq!(book.ts_last, UnixNanos::default());
1255    }
1256
1257    #[rstest]
1258    #[case::zero_size("0")]
1259    #[case::negative_size("-1.0")]
1260    fn test_parse_l2_order_book_snapshot_skips_unusable_orders(#[case] remaining: &str) {
1261        // The L2 snapshot aggregator drops orders whose size is zero or
1262        // negative (still constructable as `Decimal`) and keeps the remaining
1263        // valid orders; contrast with `parse_order_book_snapshot` (the deltas
1264        // variant) which errs. Non-numeric wire values are now rejected at
1265        // JSON deserialize time, so the whole `LighterOrderBookOrders`
1266        // payload errors before the aggregator runs. The Decimal model field
1267        // chose typed correctness over per-order resilience to garbage from
1268        // the venue.
1269        let instrument = create_test_instrument();
1270        let snapshot = LighterOrderBookOrders {
1271            code: 200,
1272            message: None,
1273            total_asks: 1,
1274            asks: vec![stub_simple_order("2400.00", "1.0000")],
1275            total_bids: 2,
1276            bids: vec![
1277                stub_simple_order("2000.00", remaining),
1278                stub_simple_order("1999.50", "0.5000"),
1279            ],
1280        };
1281
1282        let book = parse_l2_order_book_snapshot(
1283            &snapshot,
1284            instrument.id(),
1285            instrument.price_precision(),
1286            instrument.size_precision(),
1287        );
1288
1289        // Bad bid is dropped; the second valid bid still seeds the bid side.
1290        assert_eq!(book.best_bid_price(), Some(Price::from("1999.50")));
1291        assert_eq!(book.best_ask_price(), Some(Price::from("2400.00")));
1292    }
1293
1294    #[rstest]
1295    fn test_parse_l2_order_book_snapshot_loads_fixture() {
1296        // End-to-end against the venue fixture: 1 bid + 1 ask, cross-checks
1297        // that the two-sided shape exercised by the rest of the test suite
1298        // round-trips through the L2 snapshot parser.
1299        const HTTP_ORDER_BOOK_ORDERS: &str =
1300            include_str!("../../test_data/http_order_book_orders.json");
1301
1302        let instrument = create_test_instrument();
1303        let snapshot: LighterOrderBookOrders =
1304            serde_json::from_str(HTTP_ORDER_BOOK_ORDERS).expect("fixture deserializes");
1305
1306        let book = parse_l2_order_book_snapshot(
1307            &snapshot,
1308            instrument.id(),
1309            instrument.price_precision(),
1310            instrument.size_precision(),
1311        );
1312
1313        assert_eq!(book.best_bid_price(), Some(Price::from("2361.17")));
1314        assert_eq!(book.best_bid_size(), Some(Quantity::from("3.4125")));
1315        assert_eq!(book.best_ask_price(), Some(Price::from("2361.32")));
1316        assert_eq!(book.best_ask_size(), Some(Quantity::from("0.0317")));
1317    }
1318
1319    #[rstest]
1320    fn test_register_order_books_populates_market_registry() {
1321        let registry = MarketRegistry::new();
1322        let order_books = vec![stub_order_book("ETH", 0, LighterProductType::Perp)];
1323
1324        register_order_books(&registry, &order_books);
1325
1326        assert_eq!(registry.market_index(&instrument_id("ETH-PERP")), Some(0));
1327    }
1328
1329    #[rstest]
1330    fn test_register_perp_order_book_details_populates_market_registry() {
1331        let registry = MarketRegistry::new();
1332        let details = vec![LighterPerpOrderBookDetail {
1333            order_book: stub_order_book("ETH", 0, LighterProductType::Perp),
1334            size_decimals: 4,
1335            price_decimals: 2,
1336            quote_multiplier: 1,
1337            default_initial_margin_fraction: 500,
1338            min_initial_margin_fraction: 200,
1339            maintenance_margin_fraction: 120,
1340            closeout_margin_fraction: 80,
1341            last_trade_price: Decimal::new(235_273, 2),
1342            daily_trades_count: 0,
1343            daily_base_token_volume: Decimal::ZERO,
1344            daily_quote_token_volume: Decimal::ZERO,
1345            daily_price_low: Decimal::ZERO,
1346            daily_price_high: Decimal::ZERO,
1347            daily_price_change: Decimal::ZERO,
1348            open_interest: Decimal::ZERO,
1349            daily_chart: Default::default(),
1350            market_config: LighterMarketConfig {
1351                market_margin_mode: LighterPositionMarginMode::Cross,
1352                insurance_fund_account_index: 281474976710655,
1353                liquidation_mode: 0,
1354                force_reduce_only: false,
1355                trading_hours: String::new(),
1356                funding_fee_discounts_enabled: false,
1357                hidden: false,
1358            },
1359            strategy_index: 2,
1360        }];
1361
1362        register_perp_order_book_details(&registry, &details);
1363
1364        assert_eq!(registry.market_index(&instrument_id("ETH-PERP")), Some(0));
1365    }
1366
1367    #[rstest]
1368    fn test_parse_order_book_details_instruments_rejects_invalid_min_quantity() {
1369        // A negative `min_base_amount` is unreachable from the wire (the venue
1370        // never sends one) but the parser must still reject it rather than
1371        // panic inside `Quantity::from_decimal_dp`.
1372        let registry = MarketRegistry::new();
1373        let mut order_book = stub_order_book("ETH", 0, LighterProductType::Perp);
1374        order_book.min_base_amount = Decimal::from_str("-0.0050").unwrap();
1375        let details = vec![LighterPerpOrderBookDetail {
1376            order_book,
1377            size_decimals: 4,
1378            price_decimals: 2,
1379            quote_multiplier: 1,
1380            default_initial_margin_fraction: 500,
1381            min_initial_margin_fraction: 200,
1382            maintenance_margin_fraction: 120,
1383            closeout_margin_fraction: 80,
1384            last_trade_price: Decimal::new(235_273, 2),
1385            daily_trades_count: 0,
1386            daily_base_token_volume: Decimal::ZERO,
1387            daily_quote_token_volume: Decimal::ZERO,
1388            daily_price_low: Decimal::ZERO,
1389            daily_price_high: Decimal::ZERO,
1390            daily_price_change: Decimal::ZERO,
1391            open_interest: Decimal::ZERO,
1392            daily_chart: Default::default(),
1393            market_config: LighterMarketConfig {
1394                market_margin_mode: LighterPositionMarginMode::Cross,
1395                insurance_fund_account_index: 281474976710655,
1396                liquidation_mode: 0,
1397                force_reduce_only: false,
1398                trading_hours: String::new(),
1399                funding_fee_discounts_enabled: false,
1400                hidden: false,
1401            },
1402            strategy_index: 2,
1403        }];
1404
1405        let err =
1406            parse_order_book_details_instruments(&registry, &details, &[], UnixNanos::from(1))
1407                .unwrap_err();
1408
1409        assert!(err.to_string().contains("negative quantity"));
1410        assert!(registry.is_empty());
1411    }
1412
1413    #[rstest]
1414    fn test_parse_order_book_details_instruments_skips_invalid_row_without_registry_pollution() {
1415        let registry = MarketRegistry::new();
1416        let mut invalid = stub_perp_detail("ETH", 0);
1417        invalid.order_book.min_base_amount = Decimal::NEGATIVE_ONE;
1418        let valid = stub_perp_detail("BTC", 1);
1419
1420        let instruments = parse_order_book_details_instruments(
1421            &registry,
1422            &[invalid, valid],
1423            &[],
1424            UnixNanos::from(1),
1425        )
1426        .unwrap();
1427
1428        assert_eq!(instruments.len(), 1);
1429        assert_eq!(instruments[0].id(), instrument_id("BTC-PERP"));
1430        assert_eq!(registry.market_index(&instrument_id("BTC-PERP")), Some(1));
1431        assert_eq!(registry.market_index(&instrument_id("ETH-PERP")), None);
1432    }
1433
1434    #[rstest]
1435    fn test_parse_order_book_details_instruments_skips_invalid_spot_row() {
1436        let registry = MarketRegistry::new();
1437        let mut invalid = stub_spot_detail("ETH/USDC", 2048);
1438        invalid.order_book.min_base_amount = Decimal::NEGATIVE_ONE;
1439        let valid = stub_spot_detail("BTC/USDC", 2049);
1440
1441        let instruments = parse_order_book_details_instruments(
1442            &registry,
1443            &[],
1444            &[invalid, valid],
1445            UnixNanos::from(1),
1446        )
1447        .unwrap();
1448
1449        assert_eq!(instruments.len(), 1);
1450        assert_eq!(instruments[0].id(), instrument_id("BTC/USDC-SPOT"));
1451        assert_eq!(
1452            registry.market_index(&instrument_id("BTC/USDC-SPOT")),
1453            Some(2049),
1454        );
1455        assert_eq!(registry.market_index(&instrument_id("ETH/USDC-SPOT")), None);
1456    }
1457
1458    #[rstest]
1459    fn test_parse_order_book_details_instruments_parses_spot_pair() {
1460        let registry = MarketRegistry::new();
1461        let details = vec![stub_spot_detail("ETH/USDC", 2048)];
1462        let instrument_id = instrument_id("ETH/USDC-SPOT");
1463
1464        let instruments =
1465            parse_order_book_details_instruments(&registry, &[], &details, UnixNanos::from(1))
1466                .unwrap();
1467
1468        assert_eq!(instruments.len(), 1);
1469        assert_eq!(registry.market_index(&instrument_id), Some(2048));
1470
1471        match &instruments[0] {
1472            InstrumentAny::CurrencyPair(pair) => {
1473                assert_eq!(pair.id, instrument_id);
1474                assert_eq!(pair.raw_symbol.as_str(), "ETH/USDC");
1475                assert_eq!(pair.base_currency, Currency::from("ETH"));
1476                assert_eq!(pair.quote_currency, Currency::from("USDC"));
1477                assert_eq!(pair.price_precision, 6);
1478                assert_eq!(pair.size_precision, 6);
1479                assert_eq!(pair.price_increment, Price::from("0.000001"));
1480                assert_eq!(pair.size_increment, Quantity::from("0.000001"));
1481                assert_eq!(pair.min_quantity, Some(Quantity::from("0.005000")));
1482                assert_eq!(
1483                    pair.max_notional,
1484                    Some(Money::from("281474976.710655 USDC"))
1485                );
1486                assert_eq!(pair.min_notional, Some(Money::from("10.000000 USDC")));
1487            }
1488            other => panic!("expected currency pair, was {other:?}"),
1489        }
1490    }
1491
1492    #[rstest]
1493    fn test_parse_perp_uses_registry_venue_and_settlement_currency() {
1494        let venue = Venue::new("LIGHTER_ROBINHOOD");
1495        let settlement_currency = Currency::USDG();
1496        let registry =
1497            MarketRegistry::new_with_venue_and_settlement_currency(venue, settlement_currency);
1498
1499        let details = vec![stub_perp_detail("ETH", 0)];
1500
1501        let instruments =
1502            parse_order_book_details_instruments(&registry, &details, &[], UnixNanos::from(1))
1503                .unwrap();
1504
1505        assert_eq!(instruments.len(), 1);
1506        match &instruments[0] {
1507            InstrumentAny::CryptoPerpetual(perp) => {
1508                assert_eq!(perp.id.venue, venue);
1509                assert_eq!(perp.base_currency, Currency::from("ETH"));
1510                assert_eq!(perp.quote_currency, settlement_currency);
1511                assert_eq!(perp.settlement_currency, settlement_currency);
1512            }
1513            other => panic!("expected crypto perpetual, was {other:?}"),
1514        }
1515    }
1516
1517    #[rstest]
1518    #[case::missing_quote_separator("ETH")]
1519    #[case::missing_base("/USDC")]
1520    #[case::missing_quote("ETH/")]
1521    #[case::extra_component("ETH/USDC/EXTRA")]
1522    fn test_parse_order_book_details_instruments_rejects_invalid_spot_pair(#[case] symbol: &str) {
1523        let registry = MarketRegistry::new();
1524        let details = vec![stub_spot_detail(symbol, 2048)];
1525
1526        let error =
1527            parse_order_book_details_instruments(&registry, &[], &details, UnixNanos::from(1))
1528                .unwrap_err();
1529
1530        assert!(error.to_string().contains("BASE/QUOTE"));
1531        assert!(registry.is_empty());
1532    }
1533
1534    #[rstest]
1535    fn test_register_spot_order_book_details_populates_market_registry() {
1536        let registry = MarketRegistry::new();
1537        let details = vec![stub_spot_detail("ETH/USDC", 2048)];
1538
1539        register_spot_order_book_details(&registry, &details);
1540
1541        assert_eq!(
1542            registry.market_index(&instrument_id("ETH/USDC-SPOT")),
1543            Some(2048)
1544        );
1545    }
1546
1547    fn instrument_id(symbol: &str) -> InstrumentId {
1548        InstrumentId::new(Symbol::new(symbol), Venue::new("LIGHTER"))
1549    }
1550
1551    #[rstest]
1552    fn test_parse_order_book_details_instruments_routes_widened_ids_by_market_type() {
1553        let registry = MarketRegistry::new();
1554        let details: LighterOrderBookDetails =
1555            serde_json::from_str(HTTP_ORDER_BOOK_DETAILS_WIDENED_IDS).unwrap();
1556
1557        let instruments = parse_order_book_details_instruments(
1558            &registry,
1559            &details.order_book_details,
1560            &details.spot_order_book_details,
1561            UnixNanos::from(1),
1562        )
1563        .unwrap();
1564
1565        assert_eq!(instruments.len(), 4);
1566
1567        let eth_perp = instrument_id("ETH-PERP");
1568        let future_perp = instrument_id("FUTURE-PERP");
1569        let eth_spot = instrument_id("ETH/USDC-SPOT");
1570        let future_spot = instrument_id("FUTURE/USDC-SPOT");
1571
1572        assert_eq!(registry.market_index(&eth_perp), Some(4095));
1573        assert_eq!(registry.market_index(&future_perp), Some(40_000));
1574        assert_eq!(registry.market_index(&eth_spot), Some(4098));
1575        assert_eq!(registry.market_index(&future_spot), Some(50_000));
1576
1577        let mut kinds: Vec<(&str, &str)> = instruments
1578            .iter()
1579            .map(|instrument| match instrument {
1580                InstrumentAny::CryptoPerpetual(perp) => (perp.id.symbol.as_str(), "perp"),
1581                InstrumentAny::CurrencyPair(pair) => (pair.id.symbol.as_str(), "spot"),
1582                other => panic!("unexpected instrument kind, was {other:?}"),
1583            })
1584            .collect();
1585
1586        kinds.sort_unstable();
1587        assert_eq!(
1588            kinds,
1589            vec![
1590                ("ETH-PERP", "perp"),
1591                ("ETH/USDC-SPOT", "spot"),
1592                ("FUTURE-PERP", "perp"),
1593                ("FUTURE/USDC-SPOT", "spot"),
1594            ],
1595        );
1596    }
1597}