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