1use std::sync::LazyLock;
19
20use anyhow::Context;
21use nautilus_core::{UUID4, UnixNanos};
22use nautilus_model::{
23 data::{
24 Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
25 OrderBookDelta, OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick,
26 depth::DEPTH10_LEN,
27 },
28 enums::{
29 AccountType, AggregationSource, BookAction, LiquiditySide, OrderSide, OrderStatus,
30 OrderType, PositionSideSpecified, RecordFlag, TimeInForce, TriggerType,
31 },
32 events::{
33 AccountState, OrderAccepted, OrderCanceled, OrderExpired, OrderFilled, OrderRejected,
34 OrderTriggered, OrderUpdated,
35 },
36 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, TraderId, VenueOrderId},
37 instruments::{Instrument, InstrumentAny},
38 reports::{FillReport, OrderStatusReport, PositionStatusReport},
39 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
40};
41use rust_decimal::Decimal;
42use ustr::Ustr;
43
44use crate::{
45 common::{
46 enums::{
47 LighterCandleResolution, LighterOrderKind, LighterOrderSide, LighterOrderStatus,
48 LighterOrderTimeInForce, LighterTriggerStatus, order_side_from_is_ask,
49 },
50 parse::{parse_millis_to_nanos, price_from_decimal, quantity_from_decimal},
51 },
52 http::{
53 models::{LighterOrder, LighterPriceLevel, LighterTrade},
54 parse::parse_trade_tick,
55 },
56 websocket::{
57 dispatch::{OrderIdentity, OrderShapeSnapshot},
58 messages::{
59 LighterAsset, LighterMarketStats, LighterPosition, LighterSpotMarketStats,
60 LighterTicker, LighterUserStats, LighterWsCandle, LighterWsOrderBook,
61 },
62 },
63};
64
65const FEE_DECIMALS: u32 = 6;
71
72static FEE_USDC: LazyLock<Currency> = LazyLock::new(|| Currency::get_or_create_crypto("USDC"));
75
76pub fn parse_ws_trade_tick(
82 trade: &LighterTrade,
83 instrument: &InstrumentAny,
84 ts_init: UnixNanos,
85) -> anyhow::Result<TradeTick> {
86 parse_trade_tick(trade, instrument, ts_init)
87}
88
89pub fn parse_ws_order_book_deltas(
99 book: &LighterWsOrderBook,
100 instrument: &InstrumentAny,
101 timestamp_ms: u64,
102 is_snapshot: bool,
103 ts_init: UnixNanos,
104) -> anyhow::Result<OrderBookDeltas> {
105 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
106 let sequence = u64::try_from(book.nonce).context("negative Lighter book nonce")?;
107 let total_levels = book.bids.len() + book.asks.len();
108
109 anyhow::ensure!(
110 is_snapshot || total_levels > 0,
111 "empty Lighter WebSocket order book update",
112 );
113
114 let mut deltas = Vec::with_capacity(total_levels + usize::from(is_snapshot));
115
116 if is_snapshot {
117 let mut clear = OrderBookDelta::clear(instrument.id(), sequence, ts_event, ts_init);
118 if total_levels == 0 {
119 clear.flags |= RecordFlag::F_LAST as u8;
120 }
121 deltas.push(clear);
122 }
123
124 let mut processed = 0_usize;
125
126 for bid in &book.bids {
127 processed += 1;
128 deltas.push(parse_book_level_delta(
129 bid,
130 instrument,
131 OrderSide::Buy,
132 sequence,
133 ts_event,
134 ts_init,
135 book_flags(is_snapshot, processed, total_levels),
136 )?);
137 }
138
139 for ask in &book.asks {
140 processed += 1;
141 deltas.push(parse_book_level_delta(
142 ask,
143 instrument,
144 OrderSide::Sell,
145 sequence,
146 ts_event,
147 ts_init,
148 book_flags(is_snapshot, processed, total_levels),
149 )?);
150 }
151
152 OrderBookDeltas::new_checked(instrument.id(), deltas)
153 .context("failed to construct OrderBookDeltas from Lighter WebSocket book")
154}
155
156pub fn parse_ws_order_book_depth10(
165 book: &LighterWsOrderBook,
166 instrument: &InstrumentAny,
167 timestamp_ms: u64,
168 ts_init: UnixNanos,
169) -> anyhow::Result<OrderBookDepth10> {
170 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
171 let sequence = u64::try_from(book.nonce).context("negative Lighter book nonce")?;
172 let mut bids = [BookOrder::default(); DEPTH10_LEN];
173 let mut asks = [BookOrder::default(); DEPTH10_LEN];
174 let mut bid_counts = [0_u32; DEPTH10_LEN];
175 let mut ask_counts = [0_u32; DEPTH10_LEN];
176
177 for (idx, level) in book.bids.iter().take(DEPTH10_LEN).enumerate() {
178 bids[idx] = BookOrder::new(
179 OrderSide::Buy,
180 price_from_decimal(level.price, instrument.price_precision())?,
181 quantity_from_decimal(level.size, instrument.size_precision())?,
182 0,
183 );
184 bid_counts[idx] = 1;
185 }
186
187 for bid in bids.iter_mut().skip(book.bids.len().min(DEPTH10_LEN)) {
188 *bid = BookOrder::new(
189 OrderSide::Buy,
190 Price::zero(instrument.price_precision()),
191 Quantity::zero(instrument.size_precision()),
192 0,
193 );
194 }
195
196 for (idx, level) in book.asks.iter().take(DEPTH10_LEN).enumerate() {
197 asks[idx] = BookOrder::new(
198 OrderSide::Sell,
199 price_from_decimal(level.price, instrument.price_precision())?,
200 quantity_from_decimal(level.size, instrument.size_precision())?,
201 0,
202 );
203 ask_counts[idx] = 1;
204 }
205
206 for ask in asks.iter_mut().skip(book.asks.len().min(DEPTH10_LEN)) {
207 *ask = BookOrder::new(
208 OrderSide::Sell,
209 Price::zero(instrument.price_precision()),
210 Quantity::zero(instrument.size_precision()),
211 0,
212 );
213 }
214
215 Ok(OrderBookDepth10::new(
216 instrument.id(),
217 bids,
218 asks,
219 bid_counts,
220 ask_counts,
221 RecordFlag::F_SNAPSHOT as u8,
222 sequence,
223 ts_event,
224 ts_init,
225 ))
226}
227
228pub fn parse_ws_quote_tick(
239 ticker: &LighterTicker,
240 instrument: &InstrumentAny,
241 timestamp_ms: u64,
242 ts_init: UnixNanos,
243) -> anyhow::Result<Option<QuoteTick>> {
244 if ticker.b.price.is_zero()
248 || ticker.b.size.is_zero()
249 || ticker.a.price.is_zero()
250 || ticker.a.size.is_zero()
251 {
252 return Ok(None);
253 }
254
255 let bid_price = price_from_decimal(ticker.b.price, instrument.price_precision())?;
256 let ask_price = price_from_decimal(ticker.a.price, instrument.price_precision())?;
257 let bid_size = quantity_from_decimal(ticker.b.size, instrument.size_precision())?;
258 let ask_size = quantity_from_decimal(ticker.a.size, instrument.size_precision())?;
259 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
260
261 QuoteTick::new_checked(
262 instrument.id(),
263 bid_price,
264 ask_price,
265 bid_size,
266 ask_size,
267 ts_event,
268 ts_init,
269 )
270 .map(Some)
271 .context("failed to construct QuoteTick from Lighter ticker")
272}
273
274pub fn parse_ws_mark_price_update(
280 stats: &LighterMarketStats,
281 instrument: &InstrumentAny,
282 timestamp_ms: u64,
283 ts_init: UnixNanos,
284) -> anyhow::Result<MarkPriceUpdate> {
285 build_price_update(
286 instrument,
287 stats.mark_price,
288 timestamp_ms,
289 ts_init,
290 MarkPriceUpdate::new,
291 )
292}
293
294pub fn parse_ws_index_price_update(
300 stats: &LighterMarketStats,
301 instrument: &InstrumentAny,
302 timestamp_ms: u64,
303 ts_init: UnixNanos,
304) -> anyhow::Result<IndexPriceUpdate> {
305 build_price_update(
306 instrument,
307 stats.index_price,
308 timestamp_ms,
309 ts_init,
310 IndexPriceUpdate::new,
311 )
312}
313
314pub fn parse_ws_spot_index_price_update(
320 stats: &LighterSpotMarketStats,
321 instrument: &InstrumentAny,
322 timestamp_ms: u64,
323 ts_init: UnixNanos,
324) -> anyhow::Result<IndexPriceUpdate> {
325 build_price_update(
326 instrument,
327 stats.index_price,
328 timestamp_ms,
329 ts_init,
330 IndexPriceUpdate::new,
331 )
332}
333
334fn build_price_update<T>(
335 instrument: &InstrumentAny,
336 price: Decimal,
337 timestamp_ms: u64,
338 ts_init: UnixNanos,
339 constructor: impl FnOnce(InstrumentId, Price, UnixNanos, UnixNanos) -> T,
340) -> anyhow::Result<T> {
341 let price = price_from_decimal(price, instrument.price_precision())?;
342 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
343 Ok(constructor(instrument.id(), price, ts_event, ts_init))
344}
345
346pub fn parse_ws_funding_rate_update(
357 stats: &LighterMarketStats,
358 instrument: &InstrumentAny,
359 timestamp_ms: u64,
360 ts_init: UnixNanos,
361) -> anyhow::Result<FundingRateUpdate> {
362 let rate = stats.current_funding_rate;
363 let next_funding_ns = if stats.funding_timestamp == 0 {
364 None
365 } else {
366 Some(parse_millis_to_nanos(stats.funding_timestamp)?)
367 };
368 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
369 Ok(FundingRateUpdate::new(
370 instrument.id(),
371 rate,
372 None,
373 next_funding_ns,
374 ts_event,
375 ts_init,
376 ))
377}
378
379pub fn parse_ws_bar(
386 instrument: &InstrumentAny,
387 candle: &LighterWsCandle,
388 resolution: LighterCandleResolution,
389 ts_init: UnixNanos,
390) -> anyhow::Result<Bar> {
391 let price_precision = instrument.price_precision();
392 let size_precision = instrument.size_precision();
393
394 let open = Price::from_decimal_dp(candle.o, price_precision)
395 .map_err(|e| anyhow::anyhow!("invalid candle open: {e}"))?;
396 let high = Price::from_decimal_dp(candle.h, price_precision)
397 .map_err(|e| anyhow::anyhow!("invalid candle high: {e}"))?;
398 let low = Price::from_decimal_dp(candle.l, price_precision)
399 .map_err(|e| anyhow::anyhow!("invalid candle low: {e}"))?;
400 let close = Price::from_decimal_dp(candle.c, price_precision)
401 .map_err(|e| anyhow::anyhow!("invalid candle close: {e}"))?;
402 let volume = Quantity::from_decimal_dp(candle.v, size_precision)
403 .map_err(|e| anyhow::anyhow!("invalid candle volume: {e}"))?;
404
405 let t_ms = u64::try_from(candle.t)
406 .map_err(|_| anyhow::anyhow!("negative candle timestamp: {}", candle.t))?;
407 let ts_event = parse_millis_to_nanos(t_ms)?;
408
409 let bar_type = BarType::new(
410 instrument.id(),
411 resolution.to_bar_spec(),
412 AggregationSource::External,
413 );
414
415 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
416 .map_err(|e| anyhow::anyhow!("invalid candle bar: {e}"))
417}
418
419fn parse_book_level_delta(
420 level: &LighterPriceLevel,
421 instrument: &InstrumentAny,
422 side: OrderSide,
423 sequence: u64,
424 ts_event: UnixNanos,
425 ts_init: UnixNanos,
426 flags: u8,
427) -> anyhow::Result<OrderBookDelta> {
428 let price = price_from_decimal(level.price, instrument.price_precision())?;
429 let size = quantity_from_decimal(level.size, instrument.size_precision())?;
430 let action = if flags & RecordFlag::F_SNAPSHOT as u8 != 0 {
431 BookAction::Add
432 } else if size.is_zero() {
433 BookAction::Delete
434 } else {
435 BookAction::Update
436 };
437 let order = BookOrder::new(side, price, size, 0);
438
439 OrderBookDelta::new_checked(
440 instrument.id(),
441 action,
442 order,
443 flags,
444 sequence,
445 ts_event,
446 ts_init,
447 )
448 .context("failed to construct Lighter WebSocket book delta")
449}
450
451fn book_flags(is_snapshot: bool, processed: usize, total_levels: usize) -> u8 {
452 let mut flags = if is_snapshot {
453 RecordFlag::F_SNAPSHOT as u8
454 } else {
455 0
456 };
457
458 if processed == total_levels {
459 flags |= RecordFlag::F_LAST as u8;
460 }
461
462 flags
463}
464
465pub fn parse_ws_order_status_report(
477 order: &LighterOrder,
478 instrument: &InstrumentAny,
479 account_id: AccountId,
480 ts_init: UnixNanos,
481) -> anyhow::Result<OrderStatusReport> {
482 let instrument_id = instrument.id();
483 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
484 let order_side = order
485 .side
486 .map_or_else(|| order_side_from_is_ask(order.is_ask), nautilus_order_side);
487 let order_type = nautilus_order_type(order.order_type)?;
488
489 let (time_in_force, expire_time) =
490 nautilus_time_in_force(order.time_in_force, order.order_expiry);
491 let post_only = order.time_in_force == LighterOrderTimeInForce::PostOnly;
492
493 let quantity = quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
494 let filled_qty = quantity_from_decimal(order.filled_base_amount, instrument.size_precision())?;
495 let order_status = nautilus_order_status(order.status, &filled_qty);
496 let cancel_reason = order.status.as_cancel_reason();
497
498 let ts_accepted = parse_optional_event_millis(order.created_at)?;
499 let ts_last = parse_optional_event_millis(order.updated_at)?;
500
501 let mut report = OrderStatusReport::new(
502 account_id,
503 instrument_id,
504 None, venue_order_id,
506 order_side,
507 order_type,
508 time_in_force,
509 order_status,
510 quantity,
511 filled_qty,
512 ts_accepted,
513 ts_last,
514 ts_init,
515 Some(UUID4::new()),
516 )
517 .with_post_only(post_only)
518 .with_reduce_only(order.reduce_only);
519
520 if !order.client_order_id.is_empty() && order.client_order_id != "0" {
521 report = report.with_client_order_id(ClientOrderId::new(order.client_order_id.as_str()));
522 }
523
524 if let Some(price) = parse_optional_price(order.price, instrument.price_precision())? {
525 report = report.with_price(price);
526 }
527
528 if let Some(trigger_price) =
529 parse_optional_price(order.trigger_price, instrument.price_precision())?
530 {
531 report = report.with_trigger_price(trigger_price);
532 }
533
534 if order_type_requires_trigger_type(order_type) {
535 report = report.with_trigger_type(TriggerType::Default);
536 }
537
538 if let Some(expire) = expire_time {
539 report = report.with_expire_time(expire);
540 }
541
542 if let Some(reason) = cancel_reason {
543 report = report.with_cancel_reason(reason.to_string());
544 }
545
546 Ok(report)
554}
555
556fn order_type_requires_trigger_type(order_type: OrderType) -> bool {
557 matches!(
558 order_type,
559 OrderType::StopMarket
560 | OrderType::StopLimit
561 | OrderType::MarketIfTouched
562 | OrderType::LimitIfTouched
563 | OrderType::TrailingStopMarket
564 | OrderType::TrailingStopLimit
565 )
566}
567
568pub fn parse_ws_fill_report(
580 trade: &LighterTrade,
581 account_index: i64,
582 instrument: &InstrumentAny,
583 account_id: AccountId,
584 ts_init: UnixNanos,
585) -> anyhow::Result<Option<FillReport>> {
586 let user_is_bidder = trade.bid_account_id == account_index;
587 let user_is_asker = trade.ask_account_id == account_index;
588 if !user_is_bidder && !user_is_asker {
589 return Ok(None);
590 }
591
592 let order_side = if user_is_bidder {
593 OrderSide::Buy
594 } else {
595 OrderSide::Sell
596 };
597
598 let liquidity_side = if user_is_asker == trade.is_maker_ask {
602 LiquiditySide::Maker
603 } else {
604 LiquiditySide::Taker
605 };
606
607 let venue_order_id = if user_is_bidder {
608 venue_order_id_from(trade.bid_id_str.as_deref(), trade.bid_id)
609 } else {
610 venue_order_id_from(trade.ask_id_str.as_deref(), trade.ask_id)
611 };
612
613 let trade_id = parse_lighter_trade_id(trade)?;
614
615 let last_qty = quantity_from_decimal(trade.size, instrument.size_precision())?;
616 let last_px = price_from_decimal(trade.price, instrument.price_precision())?;
617
618 let fee_value = if liquidity_side == LiquiditySide::Maker {
619 trade.maker_fee
620 } else {
621 trade.taker_fee
622 };
623 let commission = lighter_fee_to_commission(fee_value)?;
624
625 let client_order_id = if user_is_bidder {
626 client_order_id_from(trade.bid_client_id_str.as_deref(), trade.bid_client_id)
627 } else {
628 client_order_id_from(trade.ask_client_id_str.as_deref(), trade.ask_client_id)
629 };
630
631 let timestamp_ms =
632 u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
633 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
634
635 Ok(Some(FillReport::new(
636 account_id,
637 instrument.id(),
638 venue_order_id,
639 trade_id,
640 order_side,
641 last_qty,
642 last_px,
643 commission,
644 liquidity_side,
645 client_order_id,
646 None, ts_event,
648 ts_init,
649 Some(UUID4::new()),
650 )))
651}
652
653pub(crate) fn parse_lighter_trade_id(trade: &LighterTrade) -> anyhow::Result<TradeId> {
654 match trade.trade_id_str.as_deref() {
655 Some(s) => TradeId::new_checked(s),
656 None => TradeId::new_checked(trade.trade_id.to_string()),
657 }
658 .context("invalid Lighter trade identifier")
659}
660
661#[derive(Debug, Clone)]
669#[allow(clippy::large_enum_variant)]
670pub(crate) enum ParsedOrderEvent {
671 Accepted(OrderAccepted),
672 Canceled(OrderCanceled),
673 Expired(OrderExpired),
674 Triggered(OrderTriggered),
675 Rejected(OrderRejected),
676 Updated(OrderUpdated),
677}
678
679#[derive(Debug, Clone, Copy)]
686pub(crate) struct OpenFrameContext {
687 pub(crate) accepted_already_emitted: bool,
689 pub(crate) triggered_already_emitted: bool,
691 pub(crate) shape_changed: bool,
695}
696
697pub(crate) fn lighter_order_shape(
706 order: &LighterOrder,
707 instrument: &InstrumentAny,
708) -> anyhow::Result<OrderShapeSnapshot> {
709 let quantity = quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
710 let price = parse_optional_price(order.price, instrument.price_precision())?;
711 let trigger_price = parse_optional_price(order.trigger_price, instrument.price_precision())?;
712 Ok(OrderShapeSnapshot {
713 quantity,
714 price,
715 trigger_price,
716 })
717}
718
719#[expect(
742 clippy::too_many_arguments,
743 reason = "identity and the precomputed open-frame context are independent inputs threaded by the caller"
744)]
745pub(crate) fn parse_lighter_order_event(
746 order: &LighterOrder,
747 instrument: &InstrumentAny,
748 identity: &OrderIdentity,
749 cloid: ClientOrderId,
750 account_id: AccountId,
751 trader_id: TraderId,
752 open_ctx: OpenFrameContext,
753 ts_init: UnixNanos,
754) -> anyhow::Result<Option<ParsedOrderEvent>> {
755 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
756 let ts_event = parse_optional_event_millis(order.updated_at)?;
757 let ts_accept = parse_optional_event_millis(order.created_at)?;
758
759 match order.status {
760 LighterOrderStatus::InProgress | LighterOrderStatus::Pending => Ok(None),
761 LighterOrderStatus::Open => {
762 if order.trigger_status == LighterTriggerStatus::Ready
763 && !open_ctx.triggered_already_emitted
764 {
765 let triggered = OrderTriggered::new(
766 trader_id,
767 identity.strategy_id,
768 identity.instrument_id,
769 cloid,
770 UUID4::new(),
771 ts_event,
772 ts_init,
773 false,
774 Some(venue_order_id),
775 Some(account_id),
776 );
777 Ok(Some(ParsedOrderEvent::Triggered(triggered)))
778 } else if !open_ctx.accepted_already_emitted {
779 let accepted = OrderAccepted::new(
780 trader_id,
781 identity.strategy_id,
782 identity.instrument_id,
783 cloid,
784 venue_order_id,
785 account_id,
786 UUID4::new(),
787 ts_accept,
788 ts_init,
789 false,
790 );
791 Ok(Some(ParsedOrderEvent::Accepted(accepted)))
792 } else if open_ctx.shape_changed {
793 let new_qty =
794 quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
795 let new_price = parse_optional_price(order.price, instrument.price_precision())?;
796 let new_trigger =
797 parse_optional_price(order.trigger_price, instrument.price_precision())?;
798 let updated = OrderUpdated::new(
799 trader_id,
800 identity.strategy_id,
801 identity.instrument_id,
802 cloid,
803 new_qty,
804 UUID4::new(),
805 ts_event,
806 ts_init,
807 false,
808 Some(venue_order_id),
809 Some(account_id),
810 new_price,
811 new_trigger,
812 None,
813 false,
814 );
815 Ok(Some(ParsedOrderEvent::Updated(updated)))
816 } else {
817 Ok(None)
818 }
819 }
820 LighterOrderStatus::Filled => {
821 Ok(None)
824 }
825 LighterOrderStatus::CanceledExpired => {
826 let expired = OrderExpired::new(
827 trader_id,
828 identity.strategy_id,
829 identity.instrument_id,
830 cloid,
831 UUID4::new(),
832 ts_event,
833 ts_init,
834 false,
835 Some(venue_order_id),
836 Some(account_id),
837 );
838 Ok(Some(ParsedOrderEvent::Expired(expired)))
839 }
840 LighterOrderStatus::CanceledPostOnly => {
841 let rejected = OrderRejected::new(
842 trader_id,
843 identity.strategy_id,
844 identity.instrument_id,
845 cloid,
846 account_id,
847 Ustr::from("post-only"),
848 UUID4::new(),
849 ts_event,
850 ts_init,
851 false,
852 true, );
854 Ok(Some(ParsedOrderEvent::Rejected(rejected)))
855 }
856 LighterOrderStatus::Canceled
857 | LighterOrderStatus::CanceledReduceOnly
858 | LighterOrderStatus::CanceledPositionNotAllowed
859 | LighterOrderStatus::CanceledMarginNotAllowed
860 | LighterOrderStatus::CanceledTooMuchSlippage
861 | LighterOrderStatus::CanceledNotEnoughLiquidity
862 | LighterOrderStatus::CanceledSelfTrade
863 | LighterOrderStatus::CanceledOco
864 | LighterOrderStatus::CanceledChild
865 | LighterOrderStatus::CanceledLiquidation
866 | LighterOrderStatus::CanceledInvalidBalance => {
867 let canceled = OrderCanceled::new(
868 trader_id,
869 identity.strategy_id,
870 identity.instrument_id,
871 cloid,
872 UUID4::new(),
873 ts_event,
874 ts_init,
875 false,
876 Some(venue_order_id),
877 Some(account_id),
878 );
879 Ok(Some(ParsedOrderEvent::Canceled(canceled)))
880 }
881 }
882}
883
884#[expect(
897 clippy::too_many_arguments,
898 reason = "identity and account context are independent inputs threaded by the dispatcher"
899)]
900pub(crate) fn parse_lighter_order_filled(
901 trade: &LighterTrade,
902 instrument: &InstrumentAny,
903 identity: &OrderIdentity,
904 cloid: ClientOrderId,
905 account_id: AccountId,
906 trader_id: TraderId,
907 account_index: i64,
908 ts_init: UnixNanos,
909) -> anyhow::Result<Option<OrderFilled>> {
910 let user_is_bidder = trade.bid_account_id == account_index;
911 let user_is_asker = trade.ask_account_id == account_index;
912 if !user_is_bidder && !user_is_asker {
913 return Ok(None);
914 }
915
916 let liquidity_side = if user_is_asker == trade.is_maker_ask {
917 LiquiditySide::Maker
918 } else {
919 LiquiditySide::Taker
920 };
921
922 let venue_order_id = if user_is_bidder {
923 venue_order_id_from(trade.bid_id_str.as_deref(), trade.bid_id)
924 } else {
925 venue_order_id_from(trade.ask_id_str.as_deref(), trade.ask_id)
926 };
927
928 let trade_id = match trade.trade_id_str.as_deref() {
929 Some(s) => TradeId::new_checked(s),
930 None => TradeId::new_checked(trade.trade_id.to_string()),
931 }
932 .context("invalid Lighter trade identifier")?;
933
934 let last_qty = quantity_from_decimal(trade.size, instrument.size_precision())?;
935 let last_px = price_from_decimal(trade.price, instrument.price_precision())?;
936
937 let fee_value = if liquidity_side == LiquiditySide::Maker {
938 trade.maker_fee
939 } else {
940 trade.taker_fee
941 };
942 let commission = lighter_fee_to_commission(fee_value)?;
943
944 let timestamp_ms =
945 u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
946 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
947
948 Ok(Some(OrderFilled::new(
949 trader_id,
950 identity.strategy_id,
951 identity.instrument_id,
952 cloid,
953 venue_order_id,
954 account_id,
955 trade_id,
956 identity.order_side,
957 identity.order_type,
958 last_qty,
959 last_px,
960 instrument.quote_currency(),
961 liquidity_side,
962 UUID4::new(),
963 ts_event,
964 ts_init,
965 false, None, Some(commission),
968 )))
969}
970
971pub fn parse_ws_position_status_report(
980 position: &LighterPosition,
981 instrument: &InstrumentAny,
982 account_id: AccountId,
983 ts_event: UnixNanos,
984 ts_init: UnixNanos,
985) -> anyhow::Result<PositionStatusReport> {
986 let quantity = quantity_from_decimal(position.position, instrument.size_precision())?;
987 let position_side = if quantity.is_zero() {
988 PositionSideSpecified::Flat
989 } else if position.sign < 0 {
990 PositionSideSpecified::Short
991 } else {
992 PositionSideSpecified::Long
993 };
994
995 let avg_px_open = if position_side == PositionSideSpecified::Flat {
996 None
997 } else {
998 Some(position.avg_entry_price)
999 };
1000
1001 Ok(PositionStatusReport::new(
1002 account_id,
1003 instrument.id(),
1004 position_side,
1005 quantity,
1006 ts_event,
1007 ts_init,
1008 Some(UUID4::new()),
1009 None, avg_px_open,
1011 ))
1012}
1013
1014pub fn account_balance_from_lighter_asset(asset: &LighterAsset) -> anyhow::Result<AccountBalance> {
1040 let currency = Currency::get_or_create_crypto(asset.symbol.as_str());
1041 let total = asset.balance + asset.margin_balance;
1042 let locked = asset.locked_balance;
1043 AccountBalance::from_total_and_locked(total, locked, currency)
1044 .context("failed to construct Lighter account balance")
1045}
1046
1047pub fn margin_balance_from_user_stats(stats: &LighterUserStats) -> anyhow::Result<MarginBalance> {
1069 let usdc = Currency::get_or_create_crypto("USDC");
1070 let initial_dec = (stats.collateral - stats.available_balance).max(Decimal::ZERO);
1071 let initial = Money::from_decimal(initial_dec, usdc)
1072 .map_err(|e| anyhow::anyhow!("failed to construct initial margin: {e}"))?;
1073 let maintenance = Money::from_decimal(Decimal::ZERO, usdc)
1074 .map_err(|e| anyhow::anyhow!("failed to construct maintenance margin: {e}"))?;
1075 Ok(MarginBalance::new(initial, maintenance, None))
1076}
1077
1078#[must_use]
1085pub fn build_unified_account_state(
1086 balances: Vec<AccountBalance>,
1087 margin: Option<MarginBalance>,
1088 account_id: AccountId,
1089 ts_event: UnixNanos,
1090 ts_init: UnixNanos,
1091) -> AccountState {
1092 let margins = margin.map(|m| vec![m]).unwrap_or_default();
1093 AccountState::new(
1094 account_id,
1095 AccountType::Margin,
1096 balances,
1097 margins,
1098 true,
1099 UUID4::new(),
1100 ts_event,
1101 ts_init,
1102 None,
1103 )
1104}
1105
1106fn parse_optional_event_millis(millis: i64) -> anyhow::Result<UnixNanos> {
1107 if millis <= 0 {
1108 return Ok(UnixNanos::default());
1109 }
1110 parse_millis_to_nanos(millis as u64)
1111}
1112
1113fn parse_optional_price(value: Decimal, precision: u8) -> anyhow::Result<Option<Price>> {
1114 if value.is_zero() {
1115 return Ok(None);
1116 }
1117 Price::from_decimal_dp(value, precision)
1118 .map(Some)
1119 .map_err(|e| anyhow::anyhow!("invalid price `{value}` at precision {precision}: {e}"))
1120}
1121
1122fn lighter_fee_to_commission(fee_ticks: Option<i32>) -> anyhow::Result<Money> {
1123 let ticks = fee_ticks.unwrap_or(0);
1124 let amount = Decimal::new(i64::from(ticks), FEE_DECIMALS);
1125 Money::from_decimal(amount, *FEE_USDC)
1126 .map_err(|e| anyhow::anyhow!("failed to construct Lighter commission: {e}"))
1127}
1128
1129fn nautilus_order_side(side: LighterOrderSide) -> OrderSide {
1130 match side {
1131 LighterOrderSide::Buy => OrderSide::Buy,
1132 LighterOrderSide::Sell => OrderSide::Sell,
1133 }
1134}
1135
1136fn nautilus_order_type(kind: LighterOrderKind) -> anyhow::Result<OrderType> {
1137 match kind {
1138 LighterOrderKind::Limit => Ok(OrderType::Limit),
1139 LighterOrderKind::Market => Ok(OrderType::Market),
1140 LighterOrderKind::StopLoss => Ok(OrderType::StopMarket),
1141 LighterOrderKind::StopLossLimit => Ok(OrderType::StopLimit),
1142 LighterOrderKind::TakeProfit => Ok(OrderType::MarketIfTouched),
1143 LighterOrderKind::TakeProfitLimit => Ok(OrderType::LimitIfTouched),
1144 LighterOrderKind::Twap | LighterOrderKind::TwapSub | LighterOrderKind::Liquidation => Err(
1145 anyhow::anyhow!("Lighter `{kind:?}` has no Nautilus order-type equivalent",),
1146 ),
1147 }
1148}
1149
1150fn nautilus_time_in_force(
1151 tif: LighterOrderTimeInForce,
1152 order_expiry: i64,
1153) -> (TimeInForce, Option<UnixNanos>) {
1154 match tif {
1155 LighterOrderTimeInForce::ImmediateOrCancel => (TimeInForce::Ioc, None),
1156 LighterOrderTimeInForce::PostOnly => (TimeInForce::Gtc, None),
1158 LighterOrderTimeInForce::GoodTillTime => {
1159 if order_expiry > 0 {
1162 match parse_millis_to_nanos(order_expiry as u64) {
1163 Ok(expiry) => (TimeInForce::Gtd, Some(expiry)),
1164 Err(_) => (TimeInForce::Gtc, None),
1165 }
1166 } else {
1167 (TimeInForce::Gtc, None)
1168 }
1169 }
1170 LighterOrderTimeInForce::Unknown => (TimeInForce::Gtc, None),
1171 }
1172}
1173
1174fn nautilus_order_status(status: LighterOrderStatus, filled_qty: &Quantity) -> OrderStatus {
1175 match status {
1176 LighterOrderStatus::InProgress | LighterOrderStatus::Pending => OrderStatus::Submitted,
1177 LighterOrderStatus::Open => {
1178 if filled_qty.is_zero() {
1179 OrderStatus::Accepted
1180 } else {
1181 OrderStatus::PartiallyFilled
1182 }
1183 }
1184 LighterOrderStatus::Filled => OrderStatus::Filled,
1185 LighterOrderStatus::CanceledExpired => OrderStatus::Expired,
1186 LighterOrderStatus::CanceledPostOnly => OrderStatus::Rejected,
1187 LighterOrderStatus::Canceled
1188 | LighterOrderStatus::CanceledReduceOnly
1189 | LighterOrderStatus::CanceledPositionNotAllowed
1190 | LighterOrderStatus::CanceledMarginNotAllowed
1191 | LighterOrderStatus::CanceledTooMuchSlippage
1192 | LighterOrderStatus::CanceledNotEnoughLiquidity
1193 | LighterOrderStatus::CanceledSelfTrade
1194 | LighterOrderStatus::CanceledOco
1195 | LighterOrderStatus::CanceledChild
1196 | LighterOrderStatus::CanceledLiquidation
1197 | LighterOrderStatus::CanceledInvalidBalance => OrderStatus::Canceled,
1198 }
1199}
1200
1201fn venue_order_id_from(str_field: Option<&str>, numeric_fallback: i64) -> VenueOrderId {
1205 match str_field {
1206 Some(s) => VenueOrderId::new(s),
1207 None => VenueOrderId::new(numeric_fallback.to_string()),
1208 }
1209}
1210
1211fn client_order_id_from(str_field: Option<&str>, numeric_fallback: i64) -> Option<ClientOrderId> {
1215 match str_field {
1216 Some(s) if !s.is_empty() && s != "0" => Some(ClientOrderId::new(s)),
1217 None if numeric_fallback != 0 => Some(ClientOrderId::new(numeric_fallback.to_string())),
1218 _ => None,
1219 }
1220}
1221
1222#[cfg(test)]
1223mod tests {
1224 use std::str::FromStr;
1225
1226 use nautilus_model::{
1227 enums::{BarAggregation, ContingencyType, PriceType},
1228 identifiers::{InstrumentId, StrategyId, Symbol, Venue},
1229 instruments::CryptoPerpetual,
1230 types::{Price, Quantity, currency::Currency},
1231 };
1232 use rstest::rstest;
1233 use ustr::Ustr;
1234
1235 use super::*;
1236 use crate::{
1237 common::enums::LighterTradeType,
1238 http::models::LighterTrade,
1239 websocket::messages::{LighterMarketStats, LighterSpotMarketStats},
1240 };
1241
1242 fn create_test_instrument() -> InstrumentAny {
1243 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
1244
1245 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1246 instrument_id,
1247 Symbol::new("ETH-PERP"),
1248 Currency::from("ETH"),
1249 Currency::from("USDC"),
1250 Currency::from("USDC"),
1251 false,
1252 2,
1253 4,
1254 Price::from("0.01"),
1255 Quantity::from("0.0001"),
1256 None,
1257 None,
1258 None,
1259 None,
1260 None,
1261 None,
1262 None,
1263 None,
1264 None,
1265 None,
1266 None,
1267 None,
1268 None,
1269 None,
1270 UnixNanos::default(),
1271 UnixNanos::default(),
1272 ))
1273 }
1274
1275 fn stub_book() -> LighterWsOrderBook {
1276 LighterWsOrderBook {
1277 code: 0,
1278 asks: vec![LighterPriceLevel {
1279 price: Decimal::from_str("2064.54").unwrap(),
1280 size: Decimal::from_str("0.3285").unwrap(),
1281 }],
1282 bids: vec![LighterPriceLevel {
1283 price: Decimal::from_str("2064.30").unwrap(),
1284 size: Decimal::from_str("1.0392").unwrap(),
1285 }],
1286 offset: 1558300,
1287 nonce: 9182390020,
1288 last_updated_at: 1774884082309144,
1289 begin_nonce: 9182389998,
1290 }
1291 }
1292
1293 fn stub_market_stats() -> LighterMarketStats {
1294 LighterMarketStats {
1295 symbol: Ustr::from("ETH"),
1296 market_id: 0,
1297 index_price: Decimal::from_str("2064.48").unwrap(),
1298 mark_price: Decimal::from_str("2064.47").unwrap(),
1299 mid_price: Decimal::from_str("2064.39").unwrap(),
1300 open_interest: Decimal::from_str("27250.8411").unwrap(),
1301 open_interest_limit: Decimal::from_str("50000.0000").unwrap(),
1302 funding_clamp_small: Decimal::from_str("0.0001").unwrap(),
1303 funding_clamp_big: Decimal::from_str("0.0002").unwrap(),
1304 last_trade_price: Decimal::from_str("2064.50").unwrap(),
1305 current_funding_rate: Decimal::from_str("0.000001").unwrap(),
1306 funding_rate: Decimal::from_str("0.000002").unwrap(),
1307 funding_timestamp: 1_774_886_400_000,
1308 daily_base_token_volume: Decimal::new(1_999_586_931, 4),
1309 daily_quote_token_volume: Decimal::new(471_193_598_847_246, 6),
1310 daily_price_low: Decimal::new(231_181, 2),
1311 daily_price_high: Decimal::new(2_398, 0),
1312 daily_price_change: Decimal::new(16_854_147_780_232_130, 17),
1313 }
1314 }
1315
1316 fn stub_spot_market_stats() -> LighterSpotMarketStats {
1317 LighterSpotMarketStats {
1318 symbol: Ustr::from("ETH"),
1319 market_id: 2048,
1320 index_price: Decimal::from_str("1.000000").unwrap(),
1321 mid_price: Decimal::from_str("1.000001").unwrap(),
1322 last_trade_price: Decimal::from_str("1.000002").unwrap(),
1323 daily_base_token_volume: Decimal::from(1000),
1324 daily_quote_token_volume: Decimal::new(10_001, 1),
1325 daily_price_low: Decimal::new(999_999, 6),
1326 daily_price_high: Decimal::new(1_000_002, 6),
1327 daily_price_change: Decimal::new(1, 6),
1328 }
1329 }
1330
1331 #[rstest]
1332 fn test_parse_ws_order_book_deltas_snapshot() {
1333 let instrument = create_test_instrument();
1334 let ts_init = UnixNanos::from(1);
1335
1336 let deltas =
1337 parse_ws_order_book_deltas(&stub_book(), &instrument, 1774884082326, true, ts_init)
1338 .unwrap();
1339
1340 assert_eq!(deltas.deltas.len(), 3);
1341 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1342 assert_eq!(deltas.deltas[1].action, BookAction::Add);
1343 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy);
1344 assert_eq!(deltas.deltas[1].order.price, Price::from("2064.30"));
1345 assert_eq!(deltas.deltas[1].order.size, Quantity::from("1.0392"));
1346 assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell);
1347 assert_eq!(deltas.deltas[2].order.price, Price::from("2064.54"));
1348 assert_eq!(deltas.deltas[2].order.size, Quantity::from("0.3285"));
1349 assert_eq!(deltas.deltas[0].sequence, 9_182_390_020);
1350 assert_eq!(deltas.deltas[1].sequence, 9_182_390_020);
1351 assert_eq!(deltas.deltas[2].sequence, 9_182_390_020);
1352 assert_eq!(deltas.sequence, 9_182_390_020);
1353 assert_eq!(
1354 deltas.deltas[2].flags & RecordFlag::F_LAST as u8,
1355 RecordFlag::F_LAST as u8,
1356 );
1357 }
1358
1359 #[rstest]
1360 fn test_parse_ws_order_book_deltas_update_delete_zero_size() {
1361 let instrument = create_test_instrument();
1362 let mut book = stub_book();
1363 book.asks[0].size = Decimal::ZERO;
1364
1365 let deltas = parse_ws_order_book_deltas(
1366 &book,
1367 &instrument,
1368 1774884082326,
1369 false,
1370 UnixNanos::from(1),
1371 )
1372 .unwrap();
1373
1374 assert_eq!(deltas.deltas.len(), 2);
1375 assert_eq!(deltas.deltas[0].action, BookAction::Update);
1376 assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy);
1377 assert_eq!(deltas.deltas[0].order.price, Price::from("2064.30"));
1378 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
1379 assert_eq!(deltas.deltas[1].order.side, OrderSide::Sell);
1380 assert_eq!(deltas.deltas[1].order.price, Price::from("2064.54"));
1381 }
1382
1383 #[rstest]
1384 fn test_parse_ws_order_book_deltas_rejects_negative_nonce() {
1385 let instrument = create_test_instrument();
1386 let mut book = stub_book();
1387 book.nonce = -1;
1388
1389 let err = parse_ws_order_book_deltas(
1390 &book,
1391 &instrument,
1392 1774884082326,
1393 false,
1394 UnixNanos::from(1),
1395 )
1396 .unwrap_err();
1397
1398 assert!(err.to_string().contains("negative Lighter book nonce"));
1399 }
1400
1401 #[rstest]
1402 fn test_parse_ws_order_book_deltas_rejects_empty_update() {
1403 let instrument = create_test_instrument();
1404 let mut book = stub_book();
1405 book.asks.clear();
1406 book.bids.clear();
1407
1408 let err = parse_ws_order_book_deltas(
1409 &book,
1410 &instrument,
1411 1774884082326,
1412 false,
1413 UnixNanos::from(1),
1414 )
1415 .unwrap_err();
1416
1417 assert!(
1418 err.to_string()
1419 .contains("empty Lighter WebSocket order book update")
1420 );
1421 }
1422
1423 #[rstest]
1424 fn test_parse_ws_order_book_deltas_rejects_zero_size_snapshot_level() {
1425 let instrument = create_test_instrument();
1426 let mut book = stub_book();
1427 book.bids[0].size = Decimal::ZERO;
1428
1429 let err =
1430 parse_ws_order_book_deltas(&book, &instrument, 1774884082326, true, UnixNanos::from(1))
1431 .unwrap_err();
1432
1433 assert!(
1434 err.to_string()
1435 .contains("failed to construct Lighter WebSocket book delta")
1436 );
1437 }
1438
1439 #[rstest]
1440 fn test_parse_ws_quote_tick() {
1441 let instrument = create_test_instrument();
1442 let ticker = LighterTicker {
1443 s: Ustr::from("ETH"),
1444 a: LighterPriceLevel {
1445 price: Decimal::from_str("2064.48").unwrap(),
1446 size: Decimal::from_str("0.4950").unwrap(),
1447 },
1448 b: LighterPriceLevel {
1449 price: Decimal::from_str("2064.30").unwrap(),
1450 size: Decimal::from_str("1.0392").unwrap(),
1451 },
1452 last_updated_at: 1774883844921166,
1453 };
1454
1455 let quote = parse_ws_quote_tick(&ticker, &instrument, 1774883844933, UnixNanos::from(1))
1456 .unwrap()
1457 .expect("two-sided ticker yields a quote");
1458
1459 assert_eq!(quote.instrument_id, instrument.id());
1460 assert_eq!(quote.bid_price, Price::from("2064.30"));
1461 assert_eq!(quote.ask_price, Price::from("2064.48"));
1462 assert_eq!(quote.bid_size, Quantity::from("1.0392"));
1463 assert_eq!(quote.ask_size, Quantity::from("0.4950"));
1464 assert_eq!(quote.ts_event, UnixNanos::from(1_774_883_844_933_000_000),);
1465 }
1466
1467 #[rstest]
1468 fn test_parse_ws_quote_tick_skips_one_sided_book() {
1469 let instrument = create_test_instrument();
1470 let ticker = LighterTicker {
1471 s: Ustr::from("ETH"),
1472 a: LighterPriceLevel {
1473 price: Decimal::from_str("2064.48").unwrap(),
1474 size: Decimal::from_str("0.4950").unwrap(),
1475 },
1476 b: LighterPriceLevel {
1477 price: Decimal::ZERO,
1480 size: Decimal::ZERO,
1481 },
1482 last_updated_at: 1774883844921166,
1483 };
1484
1485 let result =
1486 parse_ws_quote_tick(&ticker, &instrument, 1774883844933, UnixNanos::from(1)).unwrap();
1487
1488 assert!(result.is_none());
1489 }
1490
1491 #[rstest]
1492 fn test_parse_ws_quote_tick_rejects_invalid_price() {
1493 let payload = serde_json::json!({
1497 "s": "ETH",
1498 "a": {"price": "not-a-price", "size": "0.4950"},
1499 "b": {"price": "2064.30", "size": "1.0392"},
1500 "last_updated_at": 1774883844921166u64,
1501 });
1502
1503 let err = serde_json::from_value::<LighterTicker>(payload).unwrap_err();
1504 assert!(err.to_string().to_lowercase().contains("decimal"));
1505 }
1506
1507 #[rstest]
1508 fn test_parse_ws_mark_price_update() {
1509 let instrument = create_test_instrument();
1510
1511 let update = parse_ws_mark_price_update(
1512 &stub_market_stats(),
1513 &instrument,
1514 1_774_883_844_933,
1515 UnixNanos::from(1),
1516 )
1517 .unwrap();
1518
1519 assert_eq!(update.instrument_id, instrument.id());
1520 assert_eq!(update.value, Price::from("2064.47"));
1521 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1522 assert_eq!(update.ts_init, UnixNanos::from(1));
1523 }
1524
1525 #[rstest]
1526 fn test_parse_ws_index_price_update() {
1527 let instrument = create_test_instrument();
1528
1529 let update = parse_ws_index_price_update(
1530 &stub_market_stats(),
1531 &instrument,
1532 1_774_883_844_933,
1533 UnixNanos::from(1),
1534 )
1535 .unwrap();
1536
1537 assert_eq!(update.instrument_id, instrument.id());
1538 assert_eq!(update.value, Price::from("2064.48"));
1539 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1540 }
1541
1542 #[rstest]
1543 fn test_parse_ws_spot_index_price_update() {
1544 let instrument = create_test_instrument();
1545
1546 let update = parse_ws_spot_index_price_update(
1547 &stub_spot_market_stats(),
1548 &instrument,
1549 1_774_883_844_933,
1550 UnixNanos::from(1),
1551 )
1552 .unwrap();
1553
1554 assert_eq!(update.instrument_id, instrument.id());
1555 assert_eq!(update.value, Price::from("1.00"));
1556 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1557 }
1558
1559 #[rstest]
1560 fn test_parse_ws_funding_rate_update_uses_current_funding_rate() {
1561 let instrument = create_test_instrument();
1562
1563 let update = parse_ws_funding_rate_update(
1564 &stub_market_stats(),
1565 &instrument,
1566 1_774_883_844_933,
1567 UnixNanos::from(1),
1568 )
1569 .unwrap();
1570
1571 assert_eq!(update.instrument_id, instrument.id());
1572 assert_eq!(update.rate.to_string(), "0.000001");
1573 assert_eq!(
1574 update.next_funding_ns,
1575 Some(UnixNanos::from(1_774_886_400_000_000_000))
1576 );
1577 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1578 }
1579
1580 #[rstest]
1587 fn test_parse_ws_mark_price_update_reads_mark_field_only() {
1588 let instrument = create_test_instrument();
1589 let mut stats = stub_market_stats();
1590 let sentinel = Decimal::from_str("1234.56").unwrap();
1591 let other = Decimal::from_str("9999.99").unwrap();
1592 stats.mark_price = sentinel;
1593 stats.index_price = other;
1594 stats.mid_price = other;
1595 stats.last_trade_price = other;
1596
1597 let update =
1598 parse_ws_mark_price_update(&stats, &instrument, 1_774_883_844_933, UnixNanos::from(1))
1599 .unwrap();
1600
1601 assert_eq!(update.value, Price::from("1234.56"));
1602 }
1603
1604 #[rstest]
1605 fn test_parse_ws_index_price_update_reads_index_field_only() {
1606 let instrument = create_test_instrument();
1607 let mut stats = stub_market_stats();
1608 let sentinel = Decimal::from_str("1234.56").unwrap();
1609 let other = Decimal::from_str("9999.99").unwrap();
1610 stats.index_price = sentinel;
1611 stats.mark_price = other;
1612 stats.mid_price = other;
1613 stats.last_trade_price = other;
1614
1615 let update =
1616 parse_ws_index_price_update(&stats, &instrument, 1_774_883_844_933, UnixNanos::from(1))
1617 .unwrap();
1618
1619 assert_eq!(update.value, Price::from("1234.56"));
1620 }
1621
1622 #[rstest]
1623 fn test_parse_ws_funding_rate_update_reads_current_funding_rate_field_only() {
1624 let instrument = create_test_instrument();
1625 let mut stats = stub_market_stats();
1626 let sentinel = Decimal::from_str("0.0000123").unwrap();
1630 let other = Decimal::from_str("0.9999").unwrap();
1631 stats.current_funding_rate = sentinel;
1632 stats.funding_rate = other;
1633
1634 let update = parse_ws_funding_rate_update(
1635 &stats,
1636 &instrument,
1637 1_774_883_844_933,
1638 UnixNanos::from(1),
1639 )
1640 .unwrap();
1641
1642 assert_eq!(update.rate, sentinel);
1643 }
1644
1645 #[rstest]
1646 fn test_parse_ws_funding_rate_update_treats_zero_next_funding_as_none() {
1647 let instrument = create_test_instrument();
1648 let mut stats = stub_market_stats();
1649 stats.funding_timestamp = 0;
1650
1651 let update = parse_ws_funding_rate_update(
1652 &stats,
1653 &instrument,
1654 1_774_883_844_933,
1655 UnixNanos::from(1),
1656 )
1657 .unwrap();
1658
1659 assert_eq!(update.instrument_id, instrument.id());
1660 assert_eq!(update.next_funding_ns, None);
1661 }
1662
1663 #[rstest]
1664 fn test_parse_ws_order_book_depth10_pads_levels() {
1665 let instrument = create_test_instrument();
1666 let depth = parse_ws_order_book_depth10(
1667 &stub_book(),
1668 &instrument,
1669 1774884082326,
1670 UnixNanos::from(1),
1671 )
1672 .unwrap();
1673
1674 assert_eq!(depth.bids[0].price, Price::from("2064.30"));
1675 assert_eq!(depth.bids[0].size, Quantity::from("1.0392"));
1679 assert_eq!(depth.bids[0].side, OrderSide::Buy);
1680 assert_eq!(depth.asks[0].price, Price::from("2064.54"));
1681 assert_eq!(depth.asks[0].size, Quantity::from("0.3285"));
1682 assert_eq!(depth.asks[0].side, OrderSide::Sell);
1683 assert_eq!(depth.sequence, 9_182_390_020);
1684 assert_eq!(depth.bid_counts[0], 1);
1685 assert_eq!(depth.ask_counts[0], 1);
1686 assert_eq!(depth.bid_counts[1], 0);
1687 assert_eq!(depth.ask_counts[1], 0);
1688 assert!(depth.bids[1].size.is_zero());
1689 assert!(depth.asks[1].size.is_zero());
1690 }
1691
1692 #[rstest]
1693 fn test_parse_ws_trade_tick_delegates_trade_parser() {
1694 let instrument = create_test_instrument();
1695 let trade = LighterTrade {
1696 trade_id: 16164557907,
1697 trade_id_str: Some("16164557907".to_string()),
1698 tx_hash: "019f2b9c".to_string(),
1699 trade_type: LighterTradeType::Trade,
1700 market_id: 0,
1701 size: Decimal::from_str("0.1336").unwrap(),
1702 price: Decimal::from_str("2181.83").unwrap(),
1703 usd_amount: Decimal::from_str("291.492488").unwrap(),
1704 ask_id: 281476612587355,
1705 ask_id_str: Some("281476612587355".to_string()),
1706 bid_id: 562948334068259,
1707 bid_id_str: Some("562948334068259".to_string()),
1708 ask_client_id: 363283,
1709 ask_client_id_str: Some("363283".to_string()),
1710 bid_client_id: 23004521241,
1711 bid_client_id_str: Some("23004521241".to_string()),
1712 ask_account_id: 57890,
1713 bid_account_id: 317068,
1714 is_maker_ask: false,
1715 block_height: 198321831,
1716 timestamp: 1773854156654,
1717 taker_fee: Some(196),
1718 taker_position_size_before: None,
1719 taker_entry_quote_before: None,
1720 taker_initial_margin_fraction_before: None,
1721 taker_position_sign_changed: None,
1722 maker_fee: Some(28),
1723 maker_position_size_before: None,
1724 maker_entry_quote_before: None,
1725 maker_initial_margin_fraction_before: None,
1726 maker_position_sign_changed: None,
1727 transaction_time: 1773854156686065,
1728 ask_account_pnl: None,
1729 bid_account_pnl: None,
1730 };
1731
1732 let tick = parse_ws_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap();
1733
1734 assert_eq!(tick.trade_id.to_string(), "16164557907");
1735 assert_eq!(tick.price, Price::from("2181.83"));
1736 }
1737
1738 fn account_id() -> AccountId {
1739 AccountId::from("LIGHTER-1234")
1740 }
1741
1742 fn stub_order(status: LighterOrderStatus) -> LighterOrder {
1743 LighterOrder {
1744 order_index: 281476929510110,
1745 client_order_index: 42,
1746 order_id: "281476929510110".to_string(),
1747 client_order_id: "42".to_string(),
1748 market_index: 0,
1749 owner_account_index: 1234,
1750 initial_base_amount: Decimal::from_str("0.0050").unwrap(),
1751 price: Decimal::from_str("2352.74").unwrap(),
1752 nonce: 9182390020,
1753 remaining_base_amount: Decimal::from_str("0.0030").unwrap(),
1754 is_ask: true,
1755 base_size: 50,
1756 base_price: 235274,
1757 filled_base_amount: Decimal::from_str("0.0020").unwrap(),
1758 filled_quote_amount: Decimal::from_str("4.705480").unwrap(),
1759 side: Some(LighterOrderSide::Sell),
1760 order_type: LighterOrderKind::Limit,
1761 time_in_force: LighterOrderTimeInForce::GoodTillTime,
1762 reduce_only: false,
1763 trigger_price: Decimal::ZERO,
1764 order_expiry: 1_780_360_584_479,
1765 status,
1766 trigger_status: LighterTriggerStatus::Na,
1767 trigger_time: 0,
1768 parent_order_index: 0,
1769 parent_order_id: "0".to_string(),
1770 to_trigger_order_id_0: "0".to_string(),
1771 to_trigger_order_id_1: "0".to_string(),
1772 to_cancel_order_id_0: "0".to_string(),
1773 integrator_fee_collector_index: "0".to_string(),
1774 integrator_taker_fee: Decimal::ZERO,
1775 integrator_maker_fee: Decimal::ZERO,
1776 block_height: 227_535_532,
1777 timestamp: 1_777_941_383_576,
1778 created_at: 1_777_941_383_576,
1779 updated_at: 1_777_941_383_900,
1780 transaction_time: 1_777_941_383_576_735,
1781 }
1782 }
1783
1784 fn stub_account_trade(
1785 account_index: i64,
1786 is_maker_ask: bool,
1787 user_is_bidder: bool,
1788 ) -> LighterTrade {
1789 LighterTrade {
1790 trade_id: 19_209_006_902,
1791 trade_id_str: Some("19209006902".to_string()),
1792 tx_hash: "000000128b1ee814".to_string(),
1793 trade_type: LighterTradeType::Trade,
1794 market_id: 0,
1795 size: Decimal::from_str("0.1336").unwrap(),
1796 price: Decimal::from_str("2352.73").unwrap(),
1797 usd_amount: Decimal::from_str("314.324728").unwrap(),
1798 ask_id: 281_476_929_510_102,
1799 ask_id_str: Some("281476929510102".to_string()),
1800 bid_id: 562_947_905_631_053,
1801 bid_id_str: Some("562947905631053".to_string()),
1802 ask_client_id: 0,
1803 ask_client_id_str: Some("0".to_string()),
1804 bid_client_id: 7_001_011_966,
1805 bid_client_id_str: Some("7001011966".to_string()),
1806 ask_account_id: if user_is_bidder {
1807 91_249
1808 } else {
1809 account_index
1810 },
1811 bid_account_id: if user_is_bidder {
1812 account_index
1813 } else {
1814 91_249
1815 },
1816 is_maker_ask,
1817 block_height: 227_535_535,
1818 timestamp: 1_777_941_384_181,
1819 taker_fee: Some(196),
1820 taker_position_size_before: None,
1821 taker_entry_quote_before: None,
1822 taker_initial_margin_fraction_before: None,
1823 taker_position_sign_changed: None,
1824 maker_fee: Some(28),
1825 maker_position_size_before: None,
1826 maker_entry_quote_before: None,
1827 maker_initial_margin_fraction_before: None,
1828 maker_position_sign_changed: None,
1829 transaction_time: 1_777_941_384_181_586,
1830 ask_account_pnl: None,
1831 bid_account_pnl: None,
1832 }
1833 }
1834
1835 #[rstest]
1836 fn test_parse_ws_order_status_report_partial_fill_promotes_status() {
1837 let instrument = create_test_instrument();
1838 let order = stub_order(LighterOrderStatus::Open);
1839
1840 let report =
1841 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1842 .unwrap();
1843
1844 assert_eq!(report.venue_order_id.to_string(), "281476929510110");
1845 assert_eq!(report.client_order_id.unwrap().to_string(), "42");
1846 assert_eq!(report.order_side, OrderSide::Sell);
1847 assert_eq!(report.order_type, OrderType::Limit);
1848 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1850 assert_eq!(report.filled_qty, Quantity::from("0.0020"));
1851 assert_eq!(report.quantity, Quantity::from("0.0050"));
1852 assert_eq!(report.price, Some(Price::from("2352.74")));
1853 assert_eq!(report.trigger_price, None);
1854 assert_eq!(report.time_in_force, TimeInForce::Gtd);
1855 assert!(report.expire_time.is_some());
1856 assert_eq!(report.ts_init, UnixNanos::from(7));
1857 }
1858
1859 #[rstest]
1860 fn test_parse_ws_order_status_report_post_only_cancel_is_rejected() {
1861 let instrument = create_test_instrument();
1862 let order = stub_order(LighterOrderStatus::CanceledPostOnly);
1863
1864 let report =
1865 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1866 .unwrap();
1867
1868 assert_eq!(report.order_status, OrderStatus::Rejected);
1869 assert_eq!(report.cancel_reason.as_deref(), Some("post-only"));
1870 }
1871
1872 #[rstest]
1873 fn test_parse_ws_order_status_report_falls_back_to_is_ask() {
1874 let instrument = create_test_instrument();
1875 let mut order = stub_order(LighterOrderStatus::Open);
1876 order.side = None;
1877 order.is_ask = false;
1878
1879 let report =
1880 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1881 .unwrap();
1882
1883 assert_eq!(report.order_side, OrderSide::Buy);
1884 }
1885
1886 #[rstest]
1887 fn test_parse_ws_order_status_report_omits_parent_order_id() {
1888 let instrument = create_test_instrument();
1893 let mut order = stub_order(LighterOrderStatus::Open);
1894 order.parent_order_id = "999999".to_string();
1895 order.parent_order_index = 999_999;
1896
1897 let report =
1898 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1899 .unwrap();
1900
1901 assert!(report.parent_order_id.is_none());
1902 assert_eq!(report.contingency_type, ContingencyType::NoContingency);
1903 }
1904
1905 #[rstest]
1906 #[case::stop_market(LighterOrderKind::StopLoss, OrderType::StopMarket)]
1907 #[case::stop_limit(LighterOrderKind::StopLossLimit, OrderType::StopLimit)]
1908 #[case::market_if_touched(LighterOrderKind::TakeProfit, OrderType::MarketIfTouched)]
1909 #[case::limit_if_touched(LighterOrderKind::TakeProfitLimit, OrderType::LimitIfTouched)]
1910 fn test_parse_ws_order_status_report_conditional_sets_default_trigger_type(
1911 #[case] lighter_order_type: LighterOrderKind,
1912 #[case] expected_order_type: OrderType,
1913 ) {
1914 let instrument = create_test_instrument();
1915 let mut order = stub_order(LighterOrderStatus::Open);
1916 order.order_type = lighter_order_type;
1917 order.trigger_price = Decimal::from_str("2200.00").unwrap();
1918
1919 let report =
1920 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1921 .unwrap();
1922
1923 assert_eq!(report.order_type, expected_order_type);
1924 assert_eq!(report.trigger_price, Some(Price::from("2200.00")));
1925 assert_eq!(report.trigger_type, Some(TriggerType::Default));
1926 }
1927
1928 #[rstest]
1934 #[case::ioc(
1935 LighterOrderTimeInForce::ImmediateOrCancel,
1936 0,
1937 TimeInForce::Ioc,
1938 false,
1939 false
1940 )]
1941 #[case::post_only(LighterOrderTimeInForce::PostOnly, 0, TimeInForce::Gtc, false, true)]
1942 #[case::gtt_negative_expiry(LighterOrderTimeInForce::GoodTillTime, -1, TimeInForce::Gtc, false, false)]
1943 #[case::gtt_zero_expiry(
1944 LighterOrderTimeInForce::GoodTillTime,
1945 0,
1946 TimeInForce::Gtc,
1947 false,
1948 false
1949 )]
1950 #[case::gtt_positive_expiry(
1951 LighterOrderTimeInForce::GoodTillTime,
1952 1_780_000_000_000,
1953 TimeInForce::Gtd,
1954 true,
1955 false
1956 )]
1957 #[case::unknown(LighterOrderTimeInForce::Unknown, 0, TimeInForce::Gtc, false, false)]
1958 fn test_parse_ws_order_status_report_time_in_force_matrix(
1959 #[case] tif: LighterOrderTimeInForce,
1960 #[case] order_expiry: i64,
1961 #[case] expected_tif: TimeInForce,
1962 #[case] expects_expire_time: bool,
1963 #[case] expected_post_only: bool,
1964 ) {
1965 let instrument = create_test_instrument();
1966 let mut order = stub_order(LighterOrderStatus::Open);
1967 order.time_in_force = tif;
1968 order.order_expiry = order_expiry;
1969
1970 let report =
1971 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1972 .unwrap();
1973
1974 assert_eq!(report.time_in_force, expected_tif);
1975 assert_eq!(report.expire_time.is_some(), expects_expire_time);
1976 assert_eq!(report.post_only, expected_post_only);
1977 }
1978
1979 #[rstest]
1980 fn test_parse_ws_order_status_report_rejects_twap() {
1981 let instrument = create_test_instrument();
1982 let mut order = stub_order(LighterOrderStatus::Open);
1983 order.order_type = LighterOrderKind::Twap;
1984
1985 let err =
1986 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1987 .unwrap_err();
1988
1989 assert!(
1990 err.to_string()
1991 .contains("no Nautilus order-type equivalent")
1992 );
1993 }
1994
1995 #[rstest]
2000 #[case::bidder_maker_ask_is_taker(
2001 true,
2002 true,
2003 OrderSide::Buy,
2004 LiquiditySide::Taker,
2005 "0.000196 USDC"
2006 )]
2007 #[case::asker_maker_ask_is_maker(
2008 false,
2009 true,
2010 OrderSide::Sell,
2011 LiquiditySide::Maker,
2012 "0.000028 USDC"
2013 )]
2014 #[case::bidder_maker_bid_is_maker(
2015 true,
2016 false,
2017 OrderSide::Buy,
2018 LiquiditySide::Maker,
2019 "0.000028 USDC"
2020 )]
2021 #[case::asker_maker_bid_is_taker(
2022 false,
2023 false,
2024 OrderSide::Sell,
2025 LiquiditySide::Taker,
2026 "0.000196 USDC"
2027 )]
2028 fn test_parse_ws_fill_report_liquidity_side_matrix(
2029 #[case] user_is_bidder: bool,
2030 #[case] is_maker_ask: bool,
2031 #[case] expected_side: OrderSide,
2032 #[case] expected_liquidity: LiquiditySide,
2033 #[case] expected_commission: &str,
2034 ) {
2035 let instrument = create_test_instrument();
2036 let trade = stub_account_trade(1234, is_maker_ask, user_is_bidder);
2037
2038 let report =
2039 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(9))
2040 .unwrap()
2041 .expect("user-side fill");
2042
2043 assert_eq!(report.order_side, expected_side);
2044 assert_eq!(report.liquidity_side, expected_liquidity);
2045 assert_eq!(report.last_qty, Quantity::from("0.1336"));
2046 assert_eq!(report.last_px, Price::from("2352.73"));
2047 assert_eq!(report.commission, Money::from(expected_commission));
2048 let expected_voi = if user_is_bidder {
2049 "562947905631053"
2050 } else {
2051 "281476929510102"
2052 };
2053 assert_eq!(report.venue_order_id.to_string(), expected_voi);
2054 }
2055
2056 #[rstest]
2057 fn test_parse_ws_fill_report_skips_other_accounts() {
2058 let instrument = create_test_instrument();
2059 let trade = stub_account_trade(9999, false, true);
2060
2061 let report =
2062 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2063 .unwrap();
2064
2065 assert!(report.is_none());
2066 }
2067
2068 #[rstest]
2073 fn test_parse_ws_fill_report_venue_order_id_falls_back_to_numeric() {
2074 let instrument = create_test_instrument();
2075 let mut trade = stub_account_trade(1234, false, true);
2076 trade.bid_id_str = None;
2077 trade.ask_id_str = None;
2078
2079 let report =
2080 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2081 .unwrap()
2082 .expect("user-side fill");
2083
2084 assert_eq!(report.venue_order_id.to_string(), "562947905631053");
2086 }
2087
2088 #[rstest]
2093 fn test_parse_ws_fill_report_client_order_id_falls_back_to_numeric() {
2094 let instrument = create_test_instrument();
2095 let mut trade = stub_account_trade(1234, false, true);
2096 trade.bid_client_id_str = None;
2097 trade.ask_client_id_str = None;
2098
2099 let report =
2100 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2101 .unwrap()
2102 .expect("user-side fill");
2103
2104 assert_eq!(report.client_order_id.unwrap().to_string(), "7001011966");
2106 }
2107
2108 #[rstest]
2109 fn test_parse_ws_fill_report_client_order_id_absent_when_zero_numeric() {
2110 let instrument = create_test_instrument();
2111 let mut trade = stub_account_trade(1234, true, false);
2113 trade.ask_client_id_str = None;
2114
2115 let report =
2116 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2117 .unwrap()
2118 .expect("user-side fill");
2119
2120 assert!(report.client_order_id.is_none());
2121 }
2122
2123 #[rstest]
2124 fn test_parse_ws_fill_report_client_order_id_absent_when_string_is_zero_sentinel() {
2125 let instrument = create_test_instrument();
2126 let trade = stub_account_trade(1234, true, false);
2128
2129 let report =
2130 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2131 .unwrap()
2132 .expect("user-side fill");
2133
2134 assert!(report.client_order_id.is_none());
2135 }
2136
2137 #[rstest]
2138 fn test_parse_ws_fill_report_handles_missing_fee() {
2139 let instrument = create_test_instrument();
2140 let mut trade = stub_account_trade(1234, true, true);
2141 trade.taker_fee = None;
2142 trade.maker_fee = None;
2143
2144 let report =
2145 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2146 .unwrap()
2147 .expect("user-side fill");
2148
2149 assert_eq!(report.commission, Money::from("0 USDC"));
2150 }
2151
2152 #[rstest]
2153 fn test_parse_ws_position_status_report_long_position() {
2154 let instrument = create_test_instrument();
2155 let position = LighterPosition {
2156 market_id: 0,
2157 symbol: Ustr::from("ETH"),
2158 initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2159 open_order_count: 1,
2160 pending_order_count: 0,
2161 position_tied_order_count: 0,
2162 sign: 1,
2163 position: Decimal::from_str("1.5000").unwrap(),
2164 avg_entry_price: Decimal::from_str("2350.10").unwrap(),
2165 position_value: Decimal::from_str("3525.15").unwrap(),
2166 unrealized_pnl: Decimal::from_str("3.45").unwrap(),
2167 realized_pnl: Decimal::ZERO,
2168 liquidation_price: Decimal::from_str("1900.00").unwrap(),
2169 total_funding_paid_out: Some(Decimal::from_str("0.05").unwrap()),
2170 margin_mode: 0,
2171 allocated_margin: Decimal::from_str("176.25").unwrap(),
2172 total_discount: Some(Decimal::ZERO),
2173 };
2174
2175 let report = parse_ws_position_status_report(
2176 &position,
2177 &instrument,
2178 account_id(),
2179 UnixNanos::from(50),
2180 UnixNanos::from(50),
2181 )
2182 .unwrap();
2183
2184 assert_eq!(report.position_side, PositionSideSpecified::Long);
2185 assert_eq!(report.quantity, Quantity::from("1.5000"));
2186 assert_eq!(report.signed_decimal_qty, Decimal::new(15, 1));
2187 assert_eq!(report.avg_px_open, Some(Decimal::new(235010, 2)));
2188 assert!(report.venue_position_id.is_none());
2189 }
2190
2191 #[rstest]
2192 fn test_parse_ws_position_status_report_short_position() {
2193 let instrument = create_test_instrument();
2194 let position = LighterPosition {
2195 market_id: 0,
2196 symbol: Ustr::from("ETH"),
2197 initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2198 open_order_count: 0,
2199 pending_order_count: 0,
2200 position_tied_order_count: 0,
2201 sign: -1,
2202 position: Decimal::from_str("0.7500").unwrap(),
2203 avg_entry_price: Decimal::from_str("2400.00").unwrap(),
2204 position_value: Decimal::from_str("1800.00").unwrap(),
2205 unrealized_pnl: Decimal::ZERO,
2206 realized_pnl: Decimal::ZERO,
2207 liquidation_price: Decimal::from_str("3000.00").unwrap(),
2208 total_funding_paid_out: None,
2209 margin_mode: 0,
2210 allocated_margin: Decimal::from_str("90.00").unwrap(),
2211 total_discount: None,
2212 };
2213
2214 let report = parse_ws_position_status_report(
2215 &position,
2216 &instrument,
2217 account_id(),
2218 UnixNanos::default(),
2219 UnixNanos::default(),
2220 )
2221 .unwrap();
2222
2223 assert_eq!(report.position_side, PositionSideSpecified::Short);
2224 assert_eq!(report.quantity, Quantity::from("0.7500"));
2225 assert_eq!(report.signed_decimal_qty, Decimal::new(-75, 2));
2226 }
2227
2228 #[rstest]
2229 fn test_parse_ws_position_status_report_flat_position() {
2230 let instrument = create_test_instrument();
2231 let position = LighterPosition {
2232 market_id: 0,
2233 symbol: Ustr::from("ETH"),
2234 initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2235 open_order_count: 0,
2236 pending_order_count: 0,
2237 position_tied_order_count: 0,
2238 sign: 0,
2239 position: Decimal::ZERO,
2240 avg_entry_price: Decimal::ZERO,
2241 position_value: Decimal::ZERO,
2242 unrealized_pnl: Decimal::ZERO,
2243 realized_pnl: Decimal::ZERO,
2244 liquidation_price: Decimal::ZERO,
2245 total_funding_paid_out: None,
2246 margin_mode: 0,
2247 allocated_margin: Decimal::ZERO,
2248 total_discount: None,
2249 };
2250
2251 let report = parse_ws_position_status_report(
2252 &position,
2253 &instrument,
2254 account_id(),
2255 UnixNanos::default(),
2256 UnixNanos::default(),
2257 )
2258 .unwrap();
2259
2260 assert_eq!(report.position_side, PositionSideSpecified::Flat);
2261 assert!(report.quantity.is_zero());
2262 assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
2263 assert!(report.avg_px_open.is_none());
2264 }
2265
2266 #[rstest]
2267 fn test_account_balance_from_lighter_asset_spot_only() {
2268 let asset = LighterAsset {
2271 symbol: Ustr::from("USDC"),
2272 asset_id: 0,
2273 balance: Decimal::from_str("100.000000").unwrap(),
2274 locked_balance: Decimal::from_str("1.000000").unwrap(),
2275 margin_balance: Decimal::ZERO,
2276 margin_mode: Ustr::default(),
2277 };
2278
2279 let balance = account_balance_from_lighter_asset(&asset).unwrap();
2280 let usdc = Currency::get_or_create_crypto("USDC");
2281 assert_eq!(balance.currency, usdc);
2282 assert_eq!(balance.total, Money::from("100.000000 USDC"));
2283 assert_eq!(balance.locked, Money::from("1.000000 USDC"));
2284 assert_eq!(balance.free, Money::from("99.000000 USDC"));
2285 }
2286
2287 #[rstest]
2288 fn test_account_balance_from_lighter_asset_combines_spot_and_perp() {
2289 let asset = LighterAsset {
2295 symbol: Ustr::from("USDC"),
2296 asset_id: 3,
2297 balance: Decimal::from_str("10.000000").unwrap(),
2298 locked_balance: Decimal::ZERO,
2299 margin_balance: Decimal::from_str("40.000000").unwrap(),
2300 margin_mode: Ustr::from("disabled"),
2301 };
2302
2303 let balance = account_balance_from_lighter_asset(&asset).unwrap();
2304 assert_eq!(balance.total, Money::from("50.000000 USDC"));
2305 assert_eq!(balance.locked, Money::from("0 USDC"));
2306 assert_eq!(balance.free, Money::from("50.000000 USDC"));
2307 }
2308
2309 #[rstest]
2310 fn test_account_balance_from_lighter_asset_locks_only_spot_order_reservation() {
2311 let asset = LighterAsset {
2315 symbol: Ustr::from("USDC"),
2316 asset_id: 3,
2317 balance: Decimal::from_str("10.000000").unwrap(),
2318 locked_balance: Decimal::from_str("1.000000").unwrap(),
2319 margin_balance: Decimal::from_str("40.000000").unwrap(),
2320 margin_mode: Ustr::from("disabled"),
2321 };
2322
2323 let balance = account_balance_from_lighter_asset(&asset).unwrap();
2324 assert_eq!(balance.total, Money::from("50.000000 USDC"));
2325 assert_eq!(balance.locked, Money::from("1.000000 USDC"));
2326 assert_eq!(balance.free, Money::from("49.000000 USDC"));
2327 }
2328
2329 #[rstest]
2330 fn test_margin_balance_from_user_stats_no_positions() {
2331 let stats = LighterUserStats {
2335 account_trading_mode: 0,
2336 available_balance: Decimal::from_str("40.000000").unwrap(),
2337 buying_power: Decimal::ZERO,
2338 collateral: Decimal::from_str("40.000000").unwrap(),
2339 leverage: Decimal::ZERO,
2340 margin_usage: Decimal::ZERO,
2341 portfolio_value: Decimal::from_str("40.000000").unwrap(),
2342 cross_stats: None,
2343 total_stats: None,
2344 };
2345
2346 let margin = margin_balance_from_user_stats(&stats).unwrap();
2347 let usdc = Currency::get_or_create_crypto("USDC");
2348 assert_eq!(margin.currency, usdc);
2349 assert_eq!(margin.initial, Money::from("0 USDC"));
2350 assert_eq!(margin.maintenance, Money::from("0 USDC"));
2351 assert_eq!(margin.instrument_id, None);
2352 }
2353
2354 #[rstest]
2355 fn test_margin_balance_from_user_stats_with_position() {
2356 let stats = LighterUserStats {
2362 account_trading_mode: 0,
2363 available_balance: Decimal::from_str("35.000000").unwrap(),
2364 buying_power: Decimal::from_str("100.000000").unwrap(),
2365 collateral: Decimal::from_str("40.000000").unwrap(),
2366 leverage: Decimal::from_str("5.00").unwrap(),
2367 margin_usage: Decimal::from_str("12.50").unwrap(),
2368 portfolio_value: Decimal::from_str("40.000000").unwrap(),
2369 cross_stats: None,
2370 total_stats: None,
2371 };
2372
2373 let margin = margin_balance_from_user_stats(&stats).unwrap();
2374 assert_eq!(margin.initial, Money::from("5.000000 USDC"));
2375 assert_eq!(margin.maintenance, Money::from("0 USDC"));
2376 }
2377
2378 #[rstest]
2379 fn test_build_unified_account_state_emits_margin_account() {
2380 let asset = LighterAsset {
2381 symbol: Ustr::from("USDC"),
2382 asset_id: 3,
2383 balance: Decimal::from_str("10.000000").unwrap(),
2384 locked_balance: Decimal::ZERO,
2385 margin_balance: Decimal::from_str("40.000000").unwrap(),
2386 margin_mode: Ustr::from("disabled"),
2387 };
2388 let balances = vec![account_balance_from_lighter_asset(&asset).unwrap()];
2389
2390 let stats = LighterUserStats {
2391 account_trading_mode: 0,
2392 available_balance: Decimal::from_str("40.000000").unwrap(),
2393 buying_power: Decimal::ZERO,
2394 collateral: Decimal::from_str("40.000000").unwrap(),
2395 leverage: Decimal::ZERO,
2396 margin_usage: Decimal::ZERO,
2397 portfolio_value: Decimal::from_str("40.000000").unwrap(),
2398 cross_stats: None,
2399 total_stats: None,
2400 };
2401 let margin = margin_balance_from_user_stats(&stats).unwrap();
2402
2403 let state = build_unified_account_state(
2404 balances,
2405 Some(margin),
2406 account_id(),
2407 UnixNanos::from(1_000),
2408 UnixNanos::from(1_001),
2409 );
2410
2411 let usdc = Currency::get_or_create_crypto("USDC");
2412 assert_eq!(state.account_id, account_id());
2413 assert_eq!(state.account_type, AccountType::Margin);
2414 assert_eq!(state.base_currency, None);
2415 assert!(state.is_reported);
2416 assert_eq!(state.balances.len(), 1);
2417 assert_eq!(state.balances[0].total, Money::from("50.000000 USDC"));
2418 assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2419 assert_eq!(state.balances[0].free, Money::from("50.000000 USDC"));
2420 assert_eq!(state.margins.len(), 1);
2421 assert_eq!(state.margins[0].currency, usdc);
2422 assert_eq!(state.margins[0].initial, Money::from("0 USDC"));
2423 assert!(state.margins[0].instrument_id.is_none());
2424 }
2425
2426 fn stub_candle() -> LighterWsCandle {
2427 LighterWsCandle {
2428 t: 1_778_821_440_000,
2429 o: Decimal::new(226_420, 2),
2430 h: Decimal::new(226_434, 2),
2431 l: Decimal::new(226_336, 2),
2432 c: Decimal::new(226_397, 2),
2433 v: Decimal::new(132_237, 4),
2434 quote_volume: Decimal::ZERO,
2435 i: 0,
2436 }
2437 }
2438
2439 #[rstest]
2440 fn test_parse_ws_bar_emits_open_timestamp_and_external_last_spec() {
2441 let instrument = create_test_instrument();
2442 let candle = stub_candle();
2443
2444 let bar = parse_ws_bar(
2445 &instrument,
2446 &candle,
2447 LighterCandleResolution::OneMinute,
2448 UnixNanos::from(99_999),
2449 )
2450 .unwrap();
2451
2452 assert_eq!(bar.bar_type.instrument_id(), instrument.id());
2453 assert_eq!(bar.bar_type.spec().step.get(), 1);
2454 assert_eq!(bar.bar_type.spec().aggregation, BarAggregation::Minute);
2455 assert_eq!(bar.bar_type.spec().price_type, PriceType::Last);
2456 assert_eq!(
2457 bar.bar_type.aggregation_source(),
2458 AggregationSource::External
2459 );
2460 assert_eq!(bar.open, Price::from("2264.20"));
2461 assert_eq!(bar.high, Price::from("2264.34"));
2462 assert_eq!(bar.low, Price::from("2263.36"));
2463 assert_eq!(bar.close, Price::from("2263.97"));
2464 assert_eq!(bar.volume, Quantity::from("13.2237"));
2465 assert_eq!(bar.ts_event, UnixNanos::from(1_778_821_440_000_000_000));
2466 assert_eq!(bar.ts_init, UnixNanos::from(99_999));
2467 }
2468
2469 #[rstest]
2470 fn test_parse_ws_bar_rejects_negative_timestamp() {
2471 let instrument = create_test_instrument();
2472 let mut candle = stub_candle();
2473 candle.t = -1;
2474
2475 let err = parse_ws_bar(
2476 &instrument,
2477 &candle,
2478 LighterCandleResolution::OneMinute,
2479 UnixNanos::default(),
2480 )
2481 .unwrap_err();
2482
2483 assert!(
2484 err.to_string().contains("negative candle timestamp"),
2485 "expected negative-timestamp error, was: {err}",
2486 );
2487 }
2488
2489 fn test_identity() -> OrderIdentity {
2490 OrderIdentity {
2491 instrument_id: create_test_instrument().id(),
2492 strategy_id: StrategyId::new("S-TEST"),
2493 order_side: OrderSide::Sell,
2494 order_type: OrderType::Limit,
2495 }
2496 }
2497
2498 fn test_trader_id() -> TraderId {
2499 TraderId::new("TRADER-001")
2500 }
2501
2502 fn test_cloid() -> ClientOrderId {
2503 ClientOrderId::new("MY-ORDER-001")
2504 }
2505
2506 #[rstest]
2507 fn parse_lighter_order_event_emits_accepted_on_open() {
2508 let instrument = create_test_instrument();
2509 let mut order = stub_order(LighterOrderStatus::Open);
2510 order.filled_base_amount = Decimal::ZERO;
2511
2512 let event = parse_lighter_order_event(
2513 &order,
2514 &instrument,
2515 &test_identity(),
2516 test_cloid(),
2517 account_id(),
2518 test_trader_id(),
2519 OpenFrameContext {
2520 accepted_already_emitted: false,
2521 triggered_already_emitted: false,
2522 shape_changed: false,
2523 },
2524 UnixNanos::from(7),
2525 )
2526 .unwrap()
2527 .expect("Open with no prior accept emits Accepted");
2528
2529 match event {
2530 ParsedOrderEvent::Accepted(e) => {
2531 assert_eq!(e.client_order_id, test_cloid());
2532 assert_eq!(e.venue_order_id.to_string(), "281476929510110");
2533 }
2534 other => panic!("expected Accepted, was {other:?}"),
2535 }
2536 }
2537
2538 #[rstest]
2539 fn parse_lighter_order_event_emits_updated_only_when_shape_changed() {
2540 let instrument = create_test_instrument();
2546 let order = stub_order(LighterOrderStatus::Open);
2547
2548 let event = parse_lighter_order_event(
2549 &order,
2550 &instrument,
2551 &test_identity(),
2552 test_cloid(),
2553 account_id(),
2554 test_trader_id(),
2555 OpenFrameContext {
2556 accepted_already_emitted: true,
2557 triggered_already_emitted: false,
2558 shape_changed: true,
2559 },
2560 UnixNanos::from(7),
2561 )
2562 .unwrap()
2563 .expect("Open with shape_changed emits Updated");
2564
2565 match event {
2566 ParsedOrderEvent::Updated(e) => {
2567 assert_eq!(e.client_order_id, test_cloid());
2568 assert_eq!(e.quantity, Quantity::from("0.0050"));
2569 assert_eq!(e.price, Some(Price::from("2352.74")));
2570 }
2571 other => panic!("expected Updated, was {other:?}"),
2572 }
2573 }
2574
2575 #[rstest]
2576 fn parse_lighter_order_event_repeat_open_after_accept_is_silent() {
2577 let instrument = create_test_instrument();
2581 let order = stub_order(LighterOrderStatus::Open);
2582
2583 let event = parse_lighter_order_event(
2584 &order,
2585 &instrument,
2586 &test_identity(),
2587 test_cloid(),
2588 account_id(),
2589 test_trader_id(),
2590 OpenFrameContext {
2591 accepted_already_emitted: true,
2592 triggered_already_emitted: false,
2593 shape_changed: false,
2594 },
2595 UnixNanos::from(7),
2596 )
2597 .unwrap();
2598
2599 assert!(event.is_none());
2600 }
2601
2602 #[rstest]
2603 fn parse_lighter_order_event_triggered_dedup_via_open_ctx() {
2604 let instrument = create_test_instrument();
2608 let mut order = stub_order(LighterOrderStatus::Open);
2609 order.trigger_status = LighterTriggerStatus::Ready;
2610
2611 let event = parse_lighter_order_event(
2612 &order,
2613 &instrument,
2614 &test_identity(),
2615 test_cloid(),
2616 account_id(),
2617 test_trader_id(),
2618 OpenFrameContext {
2619 accepted_already_emitted: true,
2620 triggered_already_emitted: true,
2621 shape_changed: false,
2622 },
2623 UnixNanos::from(7),
2624 )
2625 .unwrap();
2626
2627 assert!(event.is_none());
2630 }
2631
2632 #[rstest]
2633 fn parse_lighter_order_event_emits_triggered_after_accept() {
2634 let instrument = create_test_instrument();
2638 let mut order = stub_order(LighterOrderStatus::Open);
2639 order.trigger_status = LighterTriggerStatus::Ready;
2640
2641 let event = parse_lighter_order_event(
2642 &order,
2643 &instrument,
2644 &test_identity(),
2645 test_cloid(),
2646 account_id(),
2647 test_trader_id(),
2648 OpenFrameContext {
2649 accepted_already_emitted: true,
2650 triggered_already_emitted: false,
2651 shape_changed: false,
2652 },
2653 UnixNanos::from(7),
2654 )
2655 .unwrap()
2656 .expect("trigger Ready after accept emits Triggered");
2657
2658 match event {
2659 ParsedOrderEvent::Triggered(_) => {}
2660 other => panic!("expected Triggered, was {other:?}"),
2661 }
2662 }
2663
2664 #[rstest]
2665 fn parse_lighter_order_event_emits_triggered_when_trigger_ready_fresh() {
2666 let instrument = create_test_instrument();
2667 let mut order = stub_order(LighterOrderStatus::Open);
2668 order.filled_base_amount = Decimal::ZERO;
2669 order.trigger_status = LighterTriggerStatus::Ready;
2670
2671 let event_fresh = parse_lighter_order_event(
2672 &order,
2673 &instrument,
2674 &test_identity(),
2675 test_cloid(),
2676 account_id(),
2677 test_trader_id(),
2678 OpenFrameContext {
2679 accepted_already_emitted: false,
2680 triggered_already_emitted: false,
2681 shape_changed: false,
2682 },
2683 UnixNanos::from(7),
2684 )
2685 .unwrap()
2686 .expect("trigger_status=Ready on fresh open emits Triggered");
2687
2688 match event_fresh {
2689 ParsedOrderEvent::Triggered(_) => {}
2690 other => panic!("expected Triggered for fresh ready trigger, was {other:?}"),
2691 }
2692 }
2693
2694 #[rstest]
2695 fn lighter_order_shape_round_trips_values() {
2696 let instrument = create_test_instrument();
2697 let order = stub_order(LighterOrderStatus::Open);
2698
2699 let shape = lighter_order_shape(&order, &instrument).unwrap();
2700
2701 assert_eq!(shape.quantity, Quantity::from("0.0050"));
2702 assert_eq!(shape.price, Some(Price::from("2352.74")));
2703 assert_eq!(shape.trigger_price, None);
2704 }
2705
2706 #[rstest]
2707 fn lighter_order_shape_distinguishes_modified_payload() {
2708 let instrument = create_test_instrument();
2709 let original = stub_order(LighterOrderStatus::Open);
2710 let mut modified = original.clone();
2711 modified.price = Decimal::from_str("2400.00").unwrap();
2712
2713 let shape_original = lighter_order_shape(&original, &instrument).unwrap();
2714 let shape_modified = lighter_order_shape(&modified, &instrument).unwrap();
2715
2716 assert_ne!(shape_original, shape_modified);
2717 }
2718
2719 #[rstest]
2720 fn parse_lighter_order_event_emits_rejected_for_post_only_cancel() {
2721 let instrument = create_test_instrument();
2722 let order = stub_order(LighterOrderStatus::CanceledPostOnly);
2723
2724 let event = parse_lighter_order_event(
2725 &order,
2726 &instrument,
2727 &test_identity(),
2728 test_cloid(),
2729 account_id(),
2730 test_trader_id(),
2731 OpenFrameContext {
2732 accepted_already_emitted: false,
2733 triggered_already_emitted: false,
2734 shape_changed: false,
2735 },
2736 UnixNanos::from(7),
2737 )
2738 .unwrap()
2739 .expect("post-only cancel emits Rejected");
2740
2741 match event {
2742 ParsedOrderEvent::Rejected(e) => {
2743 assert!(e.due_post_only);
2744 assert_eq!(e.reason.as_str(), "post-only");
2745 }
2746 other => panic!("expected Rejected, was {other:?}"),
2747 }
2748 }
2749
2750 #[rstest]
2751 fn parse_lighter_order_event_emits_expired_for_canceled_expired() {
2752 let instrument = create_test_instrument();
2753 let order = stub_order(LighterOrderStatus::CanceledExpired);
2754
2755 let event = parse_lighter_order_event(
2756 &order,
2757 &instrument,
2758 &test_identity(),
2759 test_cloid(),
2760 account_id(),
2761 test_trader_id(),
2762 OpenFrameContext {
2763 accepted_already_emitted: false,
2764 triggered_already_emitted: false,
2765 shape_changed: false,
2766 },
2767 UnixNanos::from(7),
2768 )
2769 .unwrap()
2770 .expect("canceled-expired emits Expired");
2771
2772 match event {
2773 ParsedOrderEvent::Expired(_) => {}
2774 other => panic!("expected Expired, was {other:?}"),
2775 }
2776 }
2777
2778 #[rstest]
2779 #[case::canceled(LighterOrderStatus::Canceled)]
2780 #[case::reduce_only(LighterOrderStatus::CanceledReduceOnly)]
2781 #[case::self_trade(LighterOrderStatus::CanceledSelfTrade)]
2782 #[case::liquidation(LighterOrderStatus::CanceledLiquidation)]
2783 fn parse_lighter_order_event_emits_canceled_for_other_cancel_variants(
2784 #[case] status: LighterOrderStatus,
2785 ) {
2786 let instrument = create_test_instrument();
2787 let order = stub_order(status);
2788
2789 let event = parse_lighter_order_event(
2790 &order,
2791 &instrument,
2792 &test_identity(),
2793 test_cloid(),
2794 account_id(),
2795 test_trader_id(),
2796 OpenFrameContext {
2797 accepted_already_emitted: false,
2798 triggered_already_emitted: false,
2799 shape_changed: false,
2800 },
2801 UnixNanos::from(7),
2802 )
2803 .unwrap()
2804 .expect("cancel variant emits Canceled");
2805
2806 match event {
2807 ParsedOrderEvent::Canceled(_) => {}
2808 other => panic!("expected Canceled, was {other:?}"),
2809 }
2810 }
2811
2812 #[rstest]
2813 #[case::in_progress(LighterOrderStatus::InProgress)]
2814 #[case::pending(LighterOrderStatus::Pending)]
2815 #[case::filled(LighterOrderStatus::Filled)]
2816 fn parse_lighter_order_event_returns_none_for_silent_statuses(
2817 #[case] status: LighterOrderStatus,
2818 ) {
2819 let instrument = create_test_instrument();
2823 let order = stub_order(status);
2824
2825 let event = parse_lighter_order_event(
2826 &order,
2827 &instrument,
2828 &test_identity(),
2829 test_cloid(),
2830 account_id(),
2831 test_trader_id(),
2832 OpenFrameContext {
2833 accepted_already_emitted: false,
2834 triggered_already_emitted: false,
2835 shape_changed: false,
2836 },
2837 UnixNanos::from(7),
2838 )
2839 .unwrap();
2840
2841 assert!(event.is_none(), "expected None for {status:?}");
2842 }
2843
2844 #[rstest]
2845 fn parse_lighter_order_filled_builds_order_filled_for_account() {
2846 let instrument = create_test_instrument();
2847 let trade = stub_account_trade(1234, true, true);
2848
2849 let filled = parse_lighter_order_filled(
2850 &trade,
2851 &instrument,
2852 &test_identity(),
2853 test_cloid(),
2854 account_id(),
2855 test_trader_id(),
2856 1234,
2857 UnixNanos::from(7),
2858 )
2859 .unwrap()
2860 .expect("trade involving account emits OrderFilled");
2861
2862 assert_eq!(filled.client_order_id, test_cloid());
2863 assert_eq!(filled.order_side, OrderSide::Sell); assert_eq!(filled.order_type, OrderType::Limit);
2865 assert_eq!(filled.last_qty, Quantity::from("0.1336"));
2866 assert_eq!(filled.last_px, Price::from("2352.73"));
2867 assert!(filled.commission.is_some());
2868 }
2869
2870 #[rstest]
2871 fn parse_lighter_order_filled_returns_none_for_other_account() {
2872 let instrument = create_test_instrument();
2873 let trade = stub_account_trade(1234, true, true);
2875
2876 let filled = parse_lighter_order_filled(
2877 &trade,
2878 &instrument,
2879 &test_identity(),
2880 test_cloid(),
2881 account_id(),
2882 test_trader_id(),
2883 99_999, UnixNanos::from(7),
2885 )
2886 .unwrap();
2887
2888 assert!(filled.is_none());
2889 }
2890}