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