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