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