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 LighterSimpleOrder,
638 },
639 };
640
641 const HTTP_CANDLES: &str = include_str!("../../test_data/http_candles.json");
642
643 fn create_test_instrument() -> InstrumentAny {
644 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
645
646 InstrumentAny::CryptoPerpetual(
647 CryptoPerpetual::builder()
648 .instrument_id(instrument_id)
649 .raw_symbol(Symbol::new("ETH-PERP"))
650 .base_currency(Currency::from("ETH"))
651 .quote_currency(Currency::from("USDC"))
652 .settlement_currency(Currency::from("USDC"))
653 .is_inverse(false)
654 .price_precision(2)
655 .size_precision(4)
656 .price_increment(Price::from("0.01"))
657 .size_increment(Quantity::from("0.0001"))
658 .ts_event(UnixNanos::default())
659 .ts_init(UnixNanos::default())
660 .build()
661 .unwrap(),
662 )
663 }
664
665 fn stub_trade(is_maker_ask: bool) -> LighterTrade {
666 LighterTrade {
667 trade_id: 19209006902,
668 trade_id_str: Some("19209006902".to_string()),
669 tx_hash: "000000128b1ee814".to_string(),
670 trade_type: LighterTradeType::Trade,
671 market_id: 0,
672 size: Decimal::from_str("0.1336").unwrap(),
673 price: Decimal::from_str("2352.73").unwrap(),
674 usd_amount: Decimal::from_str("314.343").unwrap(),
675 ask_id: 281476929510102,
676 ask_id_str: Some("281476929510102".to_string()),
677 bid_id: 562947905631053,
678 bid_id_str: Some("562947905631053".to_string()),
679 ask_client_id: 0,
680 ask_client_id_str: Some("0".to_string()),
681 bid_client_id: 7001011966,
682 bid_client_id_str: Some("7001011966".to_string()),
683 ask_account_id: 91249,
684 bid_account_id: 281474976618239,
685 is_maker_ask,
686 block_height: 227535535,
687 timestamp: 1777941384181,
688 taker_fee: Some(238),
689 taker_position_size_before: Some(Decimal::from_str("-31.5754").unwrap()),
690 taker_entry_quote_before: Some(Decimal::from_str("72515.683629").unwrap()),
691 taker_initial_margin_fraction_before: Some(200),
692 taker_position_sign_changed: None,
693 maker_fee: Some(34),
694 maker_position_size_before: Some(Decimal::from_str("-1.4442").unwrap()),
695 maker_entry_quote_before: Some(Decimal::from_str("3399.343032").unwrap()),
696 maker_initial_margin_fraction_before: Some(500),
697 maker_position_sign_changed: None,
698 transaction_time: 1777941384181586,
699 ask_account_pnl: None,
700 bid_account_pnl: None,
701 }
702 }
703
704 fn stub_order_book(
705 symbol: &str,
706 market_id: i16,
707 market_type: LighterProductType,
708 ) -> LighterOrderBook {
709 LighterOrderBook {
710 symbol: Ustr::from(symbol),
711 market_id,
712 market_type,
713 base_asset_id: 0,
714 quote_asset_id: 0,
715 status: LighterMarketStatus::Active,
716 taker_fee: Decimal::ZERO,
717 maker_fee: Decimal::ZERO,
718 liquidation_fee: Decimal::from_str("1.0000").unwrap(),
719 min_base_amount: Decimal::from_str("0.0050").unwrap(),
720 min_quote_amount: Decimal::from_str("10.000000").unwrap(),
721 order_quote_limit: Decimal::from_str("281474976.710655").unwrap(),
722 supported_size_decimals: 4,
723 supported_price_decimals: 2,
724 supported_quote_decimals: 6,
725 }
726 }
727
728 fn stub_perp_detail(symbol: &str, market_id: i16) -> LighterPerpOrderBookDetail {
729 LighterPerpOrderBookDetail {
730 order_book: stub_order_book(symbol, market_id, LighterProductType::Perp),
731 size_decimals: 4,
732 price_decimals: 2,
733 quote_multiplier: 1,
734 default_initial_margin_fraction: 500,
735 min_initial_margin_fraction: 200,
736 maintenance_margin_fraction: 120,
737 closeout_margin_fraction: 80,
738 last_trade_price: Decimal::new(235_273, 2),
739 daily_trades_count: 0,
740 daily_base_token_volume: Decimal::ZERO,
741 daily_quote_token_volume: Decimal::ZERO,
742 daily_price_low: Decimal::ZERO,
743 daily_price_high: Decimal::ZERO,
744 daily_price_change: Decimal::ZERO,
745 open_interest: Decimal::ZERO,
746 daily_chart: Default::default(),
747 market_config: LighterMarketConfig {
748 market_margin_mode: LighterPositionMarginMode::Cross,
749 insurance_fund_account_index: 281474976710655,
750 liquidation_mode: 0,
751 force_reduce_only: false,
752 trading_hours: String::new(),
753 funding_fee_discounts_enabled: false,
754 hidden: false,
755 },
756 strategy_index: 2,
757 }
758 }
759
760 fn stub_spot_detail(symbol: &str, market_id: i16) -> LighterSpotOrderBookDetail {
761 LighterSpotOrderBookDetail {
762 order_book: stub_order_book(symbol, market_id, LighterProductType::Spot),
763 size_decimals: 6,
764 price_decimals: 6,
765 last_trade_price: Decimal::ONE,
766 daily_trades_count: 0,
767 daily_base_token_volume: Decimal::ZERO,
768 daily_quote_token_volume: Decimal::ZERO,
769 daily_price_low: Decimal::ZERO,
770 daily_price_high: Decimal::ZERO,
771 daily_price_change: Decimal::ZERO,
772 daily_chart: Default::default(),
773 }
774 }
775
776 #[rstest]
777 fn test_parse_trade_tick_maps_aggressor_from_maker_side() {
778 let instrument = create_test_instrument();
779 let ts_init = UnixNanos::from(1);
780
781 let seller = parse_trade_tick(&stub_trade(false), &instrument, ts_init).unwrap();
782 let buyer = parse_trade_tick(&stub_trade(true), &instrument, ts_init).unwrap();
783
784 assert_eq!(seller.aggressor_side, AggressorSide::Sell);
785 assert_eq!(buyer.aggressor_side, AggressorSide::Buy);
786 assert_eq!(seller.price, Price::from("2352.73"));
787 assert_eq!(seller.size, Quantity::from("0.1336"));
788 assert_eq!(seller.trade_id.to_string(), "19209006902");
789 assert_eq!(seller.ts_event, UnixNanos::from(1_777_941_384_181_000_000),);
790 }
791
792 #[rstest]
793 fn test_parse_trade_tick_uses_numeric_trade_id_when_string_missing() {
794 let instrument = create_test_instrument();
795 let mut trade = stub_trade(false);
796 trade.trade_id_str = None;
797
798 let tick = parse_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap();
799
800 assert_eq!(tick.trade_id.to_string(), "19209006902");
801 }
802
803 #[rstest]
804 fn test_parse_trade_tick_rejects_negative_timestamp() {
805 let instrument = create_test_instrument();
806 let mut trade = stub_trade(false);
807 trade.timestamp = -1;
808
809 let err = parse_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap_err();
810
811 assert!(err.to_string().contains("negative Lighter trade timestamp"));
812 }
813
814 #[rstest]
815 fn test_parse_trade_tick_propagates_invalid_price() {
816 let payload = serde_json::json!({
821 "trade_id": 1,
822 "tx_hash": "deadbeef",
823 "type": "trade",
824 "market_id": 0,
825 "size": "1.0",
826 "price": "not-a-price",
827 "usd_amount": "0",
828 "ask_id": 0,
829 "bid_id": 0,
830 "ask_client_id": 0,
831 "bid_client_id": 0,
832 "ask_account_id": 0,
833 "bid_account_id": 0,
834 "is_maker_ask": false,
835 "block_height": 0,
836 "timestamp": 1,
837 "transaction_time": 1,
838 });
839
840 let err = serde_json::from_value::<LighterTrade>(payload).unwrap_err();
841 assert!(err.to_string().to_lowercase().contains("decimal"));
842 }
843
844 #[rstest]
845 fn test_parse_funding_rate_update_maps_direction_to_signed_rate() {
846 let instrument = create_test_instrument();
847 let ts_init = UnixNanos::from(1);
848 let long_pays = LighterFunding {
849 timestamp: 1_778_702_400,
850 value: Decimal::ZERO,
851 rate: Decimal::new(12, 4),
852 direction: LighterFundingDirection::Long,
853 };
854 let short_pays = LighterFunding {
855 direction: LighterFundingDirection::Short,
856 ..long_pays
857 };
858
859 let positive =
860 parse_funding_rate_update(&long_pays, instrument.id(), Some(60), ts_init).unwrap();
861 let negative =
862 parse_funding_rate_update(&short_pays, instrument.id(), Some(60), ts_init).unwrap();
863
864 assert_eq!(positive.instrument_id, instrument.id());
865 assert_eq!(positive.rate, Decimal::new(12, 4));
866 assert_eq!(positive.interval, Some(60));
867 assert_eq!(
868 positive.ts_event,
869 UnixNanos::from(1_778_702_400_000_000_000)
870 );
871 assert_eq!(negative.rate, Decimal::new(-12, 4));
872 }
873
874 #[rstest]
875 fn test_parse_funding_rate_update_rejects_negative_timestamp() {
876 let instrument = create_test_instrument();
877 let funding = LighterFunding {
878 timestamp: -1,
879 value: Decimal::ZERO,
880 rate: Decimal::new(12, 4),
881 direction: LighterFundingDirection::Long,
882 };
883
884 let err = parse_funding_rate_update(&funding, instrument.id(), None, UnixNanos::from(1))
885 .unwrap_err();
886
887 assert!(
888 err.to_string()
889 .contains("negative Lighter funding timestamp")
890 );
891 }
892
893 fn test_bar_type(instrument_id: InstrumentId) -> BarType {
894 BarType::new(
895 instrument_id,
896 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
897 AggregationSource::External,
898 )
899 }
900
901 #[rstest]
902 fn test_parse_candle_bar_loads_fixture() {
903 let instrument = create_test_instrument();
904 let bar_type = test_bar_type(instrument.id());
905 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
906 let candle = &candles.candles[0];
907
908 let bar = parse_candle_bar(candle, bar_type, &instrument, UnixNanos::from(42)).unwrap();
909
910 assert_eq!(bar.bar_type, bar_type);
911 assert_eq!(bar.open, Price::from("2361.11"));
912 assert_eq!(bar.high, Price::from("2362.22"));
913 assert_eq!(bar.low, Price::from("2360.00"));
914 assert_eq!(bar.close, Price::from("2361.31"));
915 assert_eq!(bar.volume, Quantity::from("1.2345"));
916 assert_eq!(bar.ts_event, UnixNanos::from(1_700_000_000_000_000_000));
917 assert_eq!(bar.ts_init, UnixNanos::from(42));
918 }
919
920 #[rstest]
921 fn test_parse_candle_bar_rejects_negative_timestamp() {
922 let instrument = create_test_instrument();
923 let bar_type = test_bar_type(instrument.id());
924 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
925 let mut candle = candles.candles[0].clone();
926 candle.timestamp = -1;
927
928 let err = parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
929
930 assert!(
931 err.to_string()
932 .contains("negative Lighter candle timestamp")
933 );
934 }
935
936 #[rstest]
937 #[case("open", Decimal::ZERO)]
938 #[case("open", Decimal::NEGATIVE_ONE)]
939 #[case("high", Decimal::ZERO)]
940 #[case("high", Decimal::NEGATIVE_ONE)]
941 #[case("low", Decimal::ZERO)]
942 #[case("low", Decimal::NEGATIVE_ONE)]
943 #[case("close", Decimal::ZERO)]
944 #[case("close", Decimal::NEGATIVE_ONE)]
945 fn test_parse_candle_bar_rejects_non_positive_ohlc(
946 #[case] field: &str,
947 #[case] value: Decimal,
948 ) {
949 let instrument = create_test_instrument();
950 let bar_type = test_bar_type(instrument.id());
951 let candles: LighterCandles = serde_json::from_str(HTTP_CANDLES).unwrap();
952 let mut candle = candles.candles[0].clone();
953 match field {
954 "open" => candle.open = value,
955 "high" => candle.high = value,
956 "low" => candle.low = value,
957 "close" => candle.close = value,
958 _ => unreachable!(),
959 }
960
961 let error =
962 parse_candle_bar(&candle, bar_type, &instrument, UnixNanos::from(1)).unwrap_err();
963
964 assert!(
965 error
966 .to_string()
967 .contains(&format!("non-positive candle {field}")),
968 );
969 }
970
971 #[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}