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