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