1use anyhow::Context;
19use nautilus_core::{UUID4, UnixNanos};
20use nautilus_model::{
21 data::{
22 Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
23 OrderBookDelta, OrderBookDeltas, OrderBookDepth, QuoteTick, TradeTick, depth::DEPTH10_LEN,
24 },
25 enums::{
26 AccountType, AggregationSource, BookAction, LiquiditySide, OrderSide, OrderStatus,
27 OrderType, PositionSide, RecordFlag, TimeInForce, TriggerType,
28 },
29 events::{
30 AccountState, OrderAccepted, OrderCanceled, OrderExpired, OrderFilled, OrderRejected,
31 OrderTriggered, OrderUpdated,
32 },
33 identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, TraderId, VenueOrderId},
34 instruments::{Instrument, InstrumentAny},
35 orders::{LIMIT_ORDER_TYPES, STOP_ORDER_TYPES},
36 reports::{FillReport, OrderStatusReport, PositionStatusReport},
37 types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
38};
39use rust_decimal::Decimal;
40use ustr::Ustr;
41
42use crate::{
43 common::{
44 enums::{
45 LighterCandleResolution, LighterOrderKind, LighterOrderSide, LighterOrderStatus,
46 LighterOrderTimeInForce, LighterTriggerStatus, order_side_from_is_ask,
47 },
48 parse::{parse_millis_to_nanos, price_from_decimal, quantity_from_decimal},
49 },
50 http::{
51 models::{LighterOrder, LighterPriceLevel, LighterTrade},
52 parse::parse_trade_tick,
53 },
54 websocket::{
55 dispatch::{OrderIdentity, OrderShapeSnapshot},
56 messages::{
57 LighterAsset, LighterMarketStats, LighterPosition, LighterSpotMarketStats,
58 LighterTicker, LighterUserStats, LighterWsCandle, LighterWsOrderBook,
59 },
60 },
61};
62
63const FEE_DECIMALS: u32 = 6;
69
70#[derive(Debug, thiserror::Error)]
71#[error("failed to construct Lighter commission: {detail}")]
72pub(crate) struct LighterCommissionError {
73 detail: String,
74}
75
76impl LighterCommissionError {
77 pub(crate) fn new(detail: impl Into<String>) -> Self {
78 Self {
79 detail: detail.into(),
80 }
81 }
82}
83
84pub fn parse_ws_trade_tick(
90 trade: &LighterTrade,
91 instrument: &InstrumentAny,
92 ts_init: UnixNanos,
93) -> anyhow::Result<TradeTick> {
94 parse_trade_tick(trade, instrument, ts_init)
95}
96
97pub fn parse_ws_order_book_deltas(
107 book: &LighterWsOrderBook,
108 instrument: &InstrumentAny,
109 timestamp_ms: u64,
110 is_snapshot: bool,
111 ts_init: UnixNanos,
112) -> anyhow::Result<OrderBookDeltas> {
113 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
114 let sequence = u64::try_from(book.nonce).context("negative Lighter book nonce")?;
115 let total_levels = book.bids.len() + book.asks.len();
116
117 anyhow::ensure!(
118 is_snapshot || total_levels > 0,
119 "empty Lighter WebSocket order book update",
120 );
121
122 let mut deltas = Vec::with_capacity(total_levels + usize::from(is_snapshot));
123
124 if is_snapshot {
125 let mut clear = OrderBookDelta::clear(instrument.id(), sequence, ts_event, ts_init);
126 if total_levels == 0 {
127 clear.flags |= RecordFlag::F_LAST as u8;
128 }
129 deltas.push(clear);
130 }
131
132 let mut processed = 0_usize;
133
134 for bid in &book.bids {
135 processed += 1;
136 deltas.push(parse_book_level_delta(
137 bid,
138 instrument,
139 OrderSide::Buy,
140 sequence,
141 ts_event,
142 ts_init,
143 book_flags(is_snapshot, processed, total_levels),
144 )?);
145 }
146
147 for ask in &book.asks {
148 processed += 1;
149 deltas.push(parse_book_level_delta(
150 ask,
151 instrument,
152 OrderSide::Sell,
153 sequence,
154 ts_event,
155 ts_init,
156 book_flags(is_snapshot, processed, total_levels),
157 )?);
158 }
159
160 OrderBookDeltas::new_checked(instrument.id(), deltas)
161 .context("failed to construct OrderBookDeltas from Lighter WebSocket book")
162}
163
164pub fn parse_ws_order_book_depth(
173 book: &LighterWsOrderBook,
174 instrument: &InstrumentAny,
175 timestamp_ms: u64,
176 ts_init: UnixNanos,
177) -> anyhow::Result<OrderBookDepth> {
178 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
179 let sequence = u64::try_from(book.nonce).context("negative Lighter book nonce")?;
180 let mut bids = [BookOrder::default(); DEPTH10_LEN];
181 let mut asks = [BookOrder::default(); DEPTH10_LEN];
182 let mut bid_counts = [0_u32; DEPTH10_LEN];
183 let mut ask_counts = [0_u32; DEPTH10_LEN];
184
185 for (idx, level) in book.bids.iter().take(DEPTH10_LEN).enumerate() {
186 bids[idx] = BookOrder::new(
187 OrderSide::Buy,
188 price_from_decimal(level.price, instrument.price_precision())?,
189 quantity_from_decimal(level.size, instrument.size_precision())?,
190 0,
191 );
192 bid_counts[idx] = 1;
193 }
194
195 for bid in bids.iter_mut().skip(book.bids.len().min(DEPTH10_LEN)) {
196 *bid = BookOrder::new(
197 OrderSide::Buy,
198 Price::zero(instrument.price_precision()),
199 Quantity::zero(instrument.size_precision()),
200 0,
201 );
202 }
203
204 for (idx, level) in book.asks.iter().take(DEPTH10_LEN).enumerate() {
205 asks[idx] = BookOrder::new(
206 OrderSide::Sell,
207 price_from_decimal(level.price, instrument.price_precision())?,
208 quantity_from_decimal(level.size, instrument.size_precision())?,
209 0,
210 );
211 ask_counts[idx] = 1;
212 }
213
214 for ask in asks.iter_mut().skip(book.asks.len().min(DEPTH10_LEN)) {
215 *ask = BookOrder::new(
216 OrderSide::Sell,
217 Price::zero(instrument.price_precision()),
218 Quantity::zero(instrument.size_precision()),
219 0,
220 );
221 }
222
223 Ok(OrderBookDepth::new(
224 instrument.id(),
225 bids,
226 asks,
227 bid_counts,
228 ask_counts,
229 RecordFlag::F_SNAPSHOT as u8,
230 sequence,
231 ts_event,
232 ts_init,
233 ))
234}
235
236pub fn parse_ws_quote_tick(
247 ticker: &LighterTicker,
248 instrument: &InstrumentAny,
249 timestamp_ms: u64,
250 ts_init: UnixNanos,
251) -> anyhow::Result<Option<QuoteTick>> {
252 if ticker.b.price.is_zero()
256 || ticker.b.size.is_zero()
257 || ticker.a.price.is_zero()
258 || ticker.a.size.is_zero()
259 {
260 return Ok(None);
261 }
262
263 let bid_price = price_from_decimal(ticker.b.price, instrument.price_precision())?;
264 let ask_price = price_from_decimal(ticker.a.price, instrument.price_precision())?;
265 let bid_size = quantity_from_decimal(ticker.b.size, instrument.size_precision())?;
266 let ask_size = quantity_from_decimal(ticker.a.size, instrument.size_precision())?;
267 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
268
269 QuoteTick::new_checked(
270 instrument.id(),
271 bid_price,
272 ask_price,
273 bid_size,
274 ask_size,
275 ts_event,
276 ts_init,
277 )
278 .map(Some)
279 .context("failed to construct QuoteTick from Lighter ticker")
280}
281
282pub fn parse_ws_mark_price_update(
288 stats: &LighterMarketStats,
289 instrument: &InstrumentAny,
290 timestamp_ms: u64,
291 ts_init: UnixNanos,
292) -> anyhow::Result<MarkPriceUpdate> {
293 build_price_update(
294 instrument,
295 stats.mark_price,
296 timestamp_ms,
297 ts_init,
298 MarkPriceUpdate::new,
299 )
300}
301
302pub fn parse_ws_index_price_update(
308 stats: &LighterMarketStats,
309 instrument: &InstrumentAny,
310 timestamp_ms: u64,
311 ts_init: UnixNanos,
312) -> anyhow::Result<IndexPriceUpdate> {
313 build_price_update(
314 instrument,
315 stats.index_price,
316 timestamp_ms,
317 ts_init,
318 IndexPriceUpdate::new,
319 )
320}
321
322pub fn parse_ws_spot_index_price_update(
328 stats: &LighterSpotMarketStats,
329 instrument: &InstrumentAny,
330 timestamp_ms: u64,
331 ts_init: UnixNanos,
332) -> anyhow::Result<IndexPriceUpdate> {
333 build_price_update(
334 instrument,
335 stats.index_price,
336 timestamp_ms,
337 ts_init,
338 IndexPriceUpdate::new,
339 )
340}
341
342fn build_price_update<T>(
343 instrument: &InstrumentAny,
344 price: Decimal,
345 timestamp_ms: u64,
346 ts_init: UnixNanos,
347 constructor: impl FnOnce(InstrumentId, Price, UnixNanos, UnixNanos) -> T,
348) -> anyhow::Result<T> {
349 let price = price_from_decimal(price, instrument.price_precision())?;
350 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
351 Ok(constructor(instrument.id(), price, ts_event, ts_init))
352}
353
354pub fn parse_ws_funding_rate_update(
366 stats: &LighterMarketStats,
367 instrument: &InstrumentAny,
368 timestamp_ms: u64,
369 ts_init: UnixNanos,
370) -> anyhow::Result<FundingRateUpdate> {
371 let rate = stats.current_funding_rate;
372 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
373 Ok(FundingRateUpdate::new(
374 instrument.id(),
375 rate,
376 None,
377 None,
378 ts_event,
379 ts_init,
380 ))
381}
382
383pub fn parse_ws_bar(
390 instrument: &InstrumentAny,
391 candle: &LighterWsCandle,
392 resolution: LighterCandleResolution,
393 ts_init: UnixNanos,
394) -> anyhow::Result<Bar> {
395 let price_precision = instrument.price_precision();
396 let size_precision = instrument.size_precision();
397
398 let open = Price::from_decimal_dp(candle.o, price_precision)
399 .map_err(|e| anyhow::anyhow!("invalid candle open: {e}"))?;
400 let high = Price::from_decimal_dp(candle.h, price_precision)
401 .map_err(|e| anyhow::anyhow!("invalid candle high: {e}"))?;
402 let low = Price::from_decimal_dp(candle.l, price_precision)
403 .map_err(|e| anyhow::anyhow!("invalid candle low: {e}"))?;
404 let close = Price::from_decimal_dp(candle.c, price_precision)
405 .map_err(|e| anyhow::anyhow!("invalid candle close: {e}"))?;
406 let volume = Quantity::from_decimal_dp(candle.v, size_precision)
407 .map_err(|e| anyhow::anyhow!("invalid candle volume: {e}"))?;
408
409 let t_ms = u64::try_from(candle.t)
410 .map_err(|_| anyhow::anyhow!("negative candle timestamp: {}", candle.t))?;
411 let ts_event = parse_millis_to_nanos(t_ms)?;
412
413 let bar_type = BarType::new(
414 instrument.id(),
415 resolution.to_bar_spec(),
416 AggregationSource::External,
417 );
418
419 Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
420 .map_err(|e| anyhow::anyhow!("invalid candle bar: {e}"))
421}
422
423fn parse_book_level_delta(
424 level: &LighterPriceLevel,
425 instrument: &InstrumentAny,
426 side: OrderSide,
427 sequence: u64,
428 ts_event: UnixNanos,
429 ts_init: UnixNanos,
430 flags: u8,
431) -> anyhow::Result<OrderBookDelta> {
432 let price = price_from_decimal(level.price, instrument.price_precision())?;
433 let size = quantity_from_decimal(level.size, instrument.size_precision())?;
434 let action = if flags & RecordFlag::F_SNAPSHOT as u8 != 0 {
435 BookAction::Add
436 } else if size.is_zero() {
437 BookAction::Delete
438 } else {
439 BookAction::Update
440 };
441 let order = BookOrder::new(side, price, size, 0);
442
443 OrderBookDelta::new_checked(
444 instrument.id(),
445 action,
446 order,
447 flags,
448 sequence,
449 ts_event,
450 ts_init,
451 )
452 .context("failed to construct Lighter WebSocket book delta")
453}
454
455fn book_flags(is_snapshot: bool, processed: usize, total_levels: usize) -> u8 {
456 let mut flags = if is_snapshot {
457 RecordFlag::F_SNAPSHOT as u8
458 } else {
459 0
460 };
461
462 if processed == total_levels {
463 flags |= RecordFlag::F_LAST as u8;
464 }
465
466 flags
467}
468
469pub fn parse_ws_order_status_report(
481 order: &LighterOrder,
482 instrument: &InstrumentAny,
483 account_id: AccountId,
484 ts_init: UnixNanos,
485) -> anyhow::Result<OrderStatusReport> {
486 let instrument_id = instrument.id();
487 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
488 let order_side = order
489 .side
490 .map_or_else(|| order_side_from_is_ask(order.is_ask), nautilus_order_side);
491 let order_type = nautilus_order_type(order.order_type)?;
492
493 let (time_in_force, expire_time) =
494 nautilus_time_in_force(order.time_in_force, order.order_expiry);
495 let post_only = order.time_in_force == LighterOrderTimeInForce::PostOnly;
496
497 let quantity = quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
498 let filled_qty = quantity_from_decimal(order.filled_base_amount, instrument.size_precision())?;
499 let order_status = nautilus_order_status(order.status, &filled_qty);
500 let cancel_reason = order.status.as_cancel_reason();
501
502 let ts_accepted = parse_optional_order_timestamp(order.created_at)?;
503 let ts_last = parse_optional_order_timestamp(order.updated_at)?;
504
505 let mut report = OrderStatusReport::new(
506 account_id,
507 instrument_id,
508 None, venue_order_id,
510 order_side.into(),
511 order_type,
512 time_in_force,
513 order_status,
514 quantity,
515 filled_qty,
516 ts_accepted,
517 ts_last,
518 ts_init,
519 Some(UUID4::new()),
520 )
521 .with_post_only(post_only)
522 .with_reduce_only(order.reduce_only);
523
524 if !order.client_order_id.is_empty() && order.client_order_id != "0" {
525 report = report.with_client_order_id(ClientOrderId::new(order.client_order_id.as_str()));
526 }
527
528 if let Some(price) = parse_optional_price(order.price, instrument.price_precision())? {
529 report = report.with_price(price);
530 }
531
532 if let Some(trigger_price) =
533 parse_optional_price(order.trigger_price, instrument.price_precision())?
534 {
535 report = report.with_trigger_price(trigger_price);
536 }
537
538 if order_type_requires_trigger_type(order_type) {
539 report = report.with_trigger_type(TriggerType::Default);
540 }
541
542 if let Some(expire) = expire_time {
543 report = report.with_expire_time(expire);
544 }
545
546 if let Some(reason) = cancel_reason {
547 report = report.with_cancel_reason(reason.to_string());
548 }
549
550 Ok(report)
558}
559
560fn order_type_requires_trigger_type(order_type: OrderType) -> bool {
561 matches!(
562 order_type,
563 OrderType::StopMarket
564 | OrderType::StopLimit
565 | OrderType::MarketIfTouched
566 | OrderType::LimitIfTouched
567 | OrderType::TrailingStopMarket
568 | OrderType::TrailingStopLimit
569 )
570}
571
572pub fn parse_ws_fill_report(
584 trade: &LighterTrade,
585 account_index: i64,
586 instrument: &InstrumentAny,
587 account_id: AccountId,
588 ts_init: UnixNanos,
589) -> anyhow::Result<Option<FillReport>> {
590 let user_is_bidder = trade.bid_account_id == account_index;
591 let user_is_asker = trade.ask_account_id == account_index;
592 if !user_is_bidder && !user_is_asker {
593 return Ok(None);
594 }
595
596 let order_side = if user_is_bidder {
597 OrderSide::Buy
598 } else {
599 OrderSide::Sell
600 };
601
602 let liquidity_side = if user_is_asker == trade.is_maker_ask {
606 LiquiditySide::Maker
607 } else {
608 LiquiditySide::Taker
609 };
610
611 let venue_order_id = if user_is_bidder {
612 venue_order_id_from(trade.bid_id_str.as_deref(), trade.bid_id)
613 } else {
614 venue_order_id_from(trade.ask_id_str.as_deref(), trade.ask_id)
615 };
616
617 let trade_id = parse_lighter_trade_id(trade)?;
618
619 let last_qty = quantity_from_decimal(trade.size, instrument.size_precision())?;
620 let last_px = price_from_decimal(trade.price, instrument.price_precision())?;
621
622 let fee_value = if liquidity_side == LiquiditySide::Maker {
623 trade.maker_fee
624 } else {
625 trade.taker_fee
626 };
627 let commission = lighter_fee_to_commission(fee_value, instrument.quote_currency())?;
628
629 let client_order_id = if user_is_bidder {
630 client_order_id_from(trade.bid_client_id_str.as_deref(), trade.bid_client_id)
631 } else {
632 client_order_id_from(trade.ask_client_id_str.as_deref(), trade.ask_client_id)
633 };
634
635 let timestamp_ms =
636 u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
637 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
638
639 Ok(Some(FillReport::new(
640 account_id,
641 instrument.id(),
642 venue_order_id,
643 trade_id,
644 order_side,
645 last_qty,
646 last_px,
647 commission,
648 liquidity_side,
649 client_order_id,
650 None, ts_event,
652 ts_init,
653 Some(UUID4::new()),
654 )))
655}
656
657pub(crate) fn parse_lighter_trade_id(trade: &LighterTrade) -> anyhow::Result<TradeId> {
658 match trade.trade_id_str.as_deref() {
659 Some(s) => TradeId::new_checked(s),
660 None => TradeId::new_checked(trade.trade_id.to_string()),
661 }
662 .context("invalid Lighter trade identifier")
663}
664
665#[derive(Debug, Clone)]
673#[allow(clippy::large_enum_variant)]
674pub(crate) enum ParsedOrderEvent {
675 Accepted(OrderAccepted),
676 Canceled(OrderCanceled),
677 Expired(OrderExpired),
678 Triggered(OrderTriggered),
679 Rejected(OrderRejected),
680 Updated(OrderUpdated),
681 UpdatedThenTriggered {
682 updated: OrderUpdated,
683 triggered: OrderTriggered,
684 },
685}
686
687#[derive(Debug, Clone, Copy)]
695pub(crate) struct OpenFrameContext {
696 pub(crate) accepted_already_emitted: bool,
698 pub(crate) triggered_already_emitted: bool,
700 pub(crate) shape_changed: bool,
704}
705
706pub(crate) fn lighter_order_shape(
715 order: &LighterOrder,
716 instrument: &InstrumentAny,
717 order_type: OrderType,
718) -> anyhow::Result<OrderShapeSnapshot> {
719 let quantity = quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
720 let price = if LIMIT_ORDER_TYPES.contains(&order_type) {
721 parse_optional_price(order.price, instrument.price_precision())?
722 } else {
723 None
724 };
725 let trigger_price = if STOP_ORDER_TYPES.contains(&order_type) {
726 parse_optional_price(order.trigger_price, instrument.price_precision())?
727 } else {
728 None
729 };
730 Ok(OrderShapeSnapshot {
731 quantity,
732 price,
733 trigger_price,
734 })
735}
736
737#[expect(
764 clippy::too_many_arguments,
765 reason = "identity and the precomputed open-frame context are independent inputs threaded by the caller"
766)]
767pub(crate) fn parse_lighter_order_event(
768 order: &LighterOrder,
769 instrument: &InstrumentAny,
770 identity: &OrderIdentity,
771 cloid: ClientOrderId,
772 account_id: AccountId,
773 trader_id: TraderId,
774 open_ctx: OpenFrameContext,
775 ts_init: UnixNanos,
776) -> anyhow::Result<Option<ParsedOrderEvent>> {
777 let venue_order_id = VenueOrderId::new(order.order_id.as_str());
778 let ts_event = parse_optional_order_timestamp(order.updated_at)?;
779 let ts_accept = parse_optional_order_timestamp(order.created_at)?;
780
781 match order.status {
782 LighterOrderStatus::InProgress => Ok(None),
783 LighterOrderStatus::Pending | LighterOrderStatus::Open => {
784 let fresh_trigger = order.status == LighterOrderStatus::Open
785 && order.trigger_status == LighterTriggerStatus::Ready
786 && !open_ctx.triggered_already_emitted;
787
788 if fresh_trigger && open_ctx.accepted_already_emitted && open_ctx.shape_changed {
789 let shape = lighter_order_shape(order, instrument, identity.order_type)?;
790 let updated = OrderUpdated::new(
791 trader_id,
792 identity.strategy_id,
793 identity.instrument_id,
794 cloid,
795 shape.quantity,
796 UUID4::new(),
797 ts_event,
798 ts_init,
799 false,
800 Some(venue_order_id),
801 Some(account_id),
802 shape.price,
803 shape.trigger_price,
804 None,
805 false,
806 );
807 let triggered = OrderTriggered::new(
808 trader_id,
809 identity.strategy_id,
810 identity.instrument_id,
811 cloid,
812 UUID4::new(),
813 ts_event,
814 ts_init,
815 false,
816 Some(venue_order_id),
817 Some(account_id),
818 );
819 Ok(Some(ParsedOrderEvent::UpdatedThenTriggered {
820 updated,
821 triggered,
822 }))
823 } else if fresh_trigger {
824 let triggered = OrderTriggered::new(
825 trader_id,
826 identity.strategy_id,
827 identity.instrument_id,
828 cloid,
829 UUID4::new(),
830 ts_event,
831 ts_init,
832 false,
833 Some(venue_order_id),
834 Some(account_id),
835 );
836 Ok(Some(ParsedOrderEvent::Triggered(triggered)))
837 } else if !open_ctx.accepted_already_emitted {
838 let accepted = OrderAccepted::new(
839 trader_id,
840 identity.strategy_id,
841 identity.instrument_id,
842 cloid,
843 venue_order_id,
844 account_id,
845 UUID4::new(),
846 ts_accept,
847 ts_init,
848 false,
849 );
850 Ok(Some(ParsedOrderEvent::Accepted(accepted)))
851 } else if open_ctx.shape_changed {
852 let shape = lighter_order_shape(order, instrument, identity.order_type)?;
853 let updated = OrderUpdated::new(
854 trader_id,
855 identity.strategy_id,
856 identity.instrument_id,
857 cloid,
858 shape.quantity,
859 UUID4::new(),
860 ts_event,
861 ts_init,
862 false,
863 Some(venue_order_id),
864 Some(account_id),
865 shape.price,
866 shape.trigger_price,
867 None,
868 false,
869 );
870 Ok(Some(ParsedOrderEvent::Updated(updated)))
871 } else {
872 Ok(None)
873 }
874 }
875 LighterOrderStatus::Filled => {
876 Ok(None)
879 }
880 LighterOrderStatus::CanceledExpired => {
881 let expired = OrderExpired::new(
882 trader_id,
883 identity.strategy_id,
884 identity.instrument_id,
885 cloid,
886 UUID4::new(),
887 ts_event,
888 ts_init,
889 false,
890 Some(venue_order_id),
891 Some(account_id),
892 );
893 Ok(Some(ParsedOrderEvent::Expired(expired)))
894 }
895 LighterOrderStatus::CanceledPostOnly => {
896 let rejected = OrderRejected::new(
897 trader_id,
898 identity.strategy_id,
899 identity.instrument_id,
900 cloid,
901 account_id,
902 Ustr::from("post-only"),
903 UUID4::new(),
904 ts_event,
905 ts_init,
906 false,
907 true, );
909 Ok(Some(ParsedOrderEvent::Rejected(rejected)))
910 }
911 LighterOrderStatus::Canceled
912 | LighterOrderStatus::CanceledReduceOnly
913 | LighterOrderStatus::CanceledPositionNotAllowed
914 | LighterOrderStatus::CanceledMarginNotAllowed
915 | LighterOrderStatus::CanceledTooMuchSlippage
916 | LighterOrderStatus::CanceledNotEnoughLiquidity
917 | LighterOrderStatus::CanceledSelfTrade
918 | LighterOrderStatus::CanceledOco
919 | LighterOrderStatus::CanceledChild
920 | LighterOrderStatus::CanceledLiquidation
921 | LighterOrderStatus::CanceledInvalidBalance => {
922 let canceled = OrderCanceled::new(
923 trader_id,
924 identity.strategy_id,
925 identity.instrument_id,
926 cloid,
927 UUID4::new(),
928 ts_event,
929 ts_init,
930 false,
931 Some(venue_order_id),
932 Some(account_id),
933 order.status.as_cancel_reason().map(Ustr::from),
934 );
935 Ok(Some(ParsedOrderEvent::Canceled(canceled)))
936 }
937 }
938}
939
940#[expect(
953 clippy::too_many_arguments,
954 reason = "identity and account context are independent inputs threaded by the dispatcher"
955)]
956pub(crate) fn parse_lighter_order_filled(
957 trade: &LighterTrade,
958 instrument: &InstrumentAny,
959 identity: &OrderIdentity,
960 cloid: ClientOrderId,
961 account_id: AccountId,
962 trader_id: TraderId,
963 account_index: i64,
964 ts_init: UnixNanos,
965) -> anyhow::Result<Option<OrderFilled>> {
966 let user_is_bidder = trade.bid_account_id == account_index;
967 let user_is_asker = trade.ask_account_id == account_index;
968 if !user_is_bidder && !user_is_asker {
969 return Ok(None);
970 }
971
972 let liquidity_side = if user_is_asker == trade.is_maker_ask {
973 LiquiditySide::Maker
974 } else {
975 LiquiditySide::Taker
976 };
977
978 let venue_order_id = if user_is_bidder {
979 venue_order_id_from(trade.bid_id_str.as_deref(), trade.bid_id)
980 } else {
981 venue_order_id_from(trade.ask_id_str.as_deref(), trade.ask_id)
982 };
983
984 let trade_id = match trade.trade_id_str.as_deref() {
985 Some(s) => TradeId::new_checked(s),
986 None => TradeId::new_checked(trade.trade_id.to_string()),
987 }
988 .context("invalid Lighter trade identifier")?;
989
990 let last_qty = quantity_from_decimal(trade.size, instrument.size_precision())?;
991 let last_px = price_from_decimal(trade.price, instrument.price_precision())?;
992
993 let fee_value = if liquidity_side == LiquiditySide::Maker {
994 trade.maker_fee
995 } else {
996 trade.taker_fee
997 };
998 let commission = lighter_fee_to_commission(fee_value, instrument.quote_currency())?;
999
1000 let timestamp_ms =
1001 u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
1002 let ts_event = parse_millis_to_nanos(timestamp_ms)?;
1003
1004 Ok(Some(OrderFilled::new(
1005 trader_id,
1006 identity.strategy_id,
1007 identity.instrument_id,
1008 cloid,
1009 venue_order_id,
1010 account_id,
1011 trade_id,
1012 identity.order_side,
1013 identity.order_type,
1014 last_qty,
1015 last_px,
1016 instrument.quote_currency(),
1017 liquidity_side,
1018 UUID4::new(),
1019 ts_event,
1020 ts_init,
1021 false, None, Some(commission),
1024 None,
1025 )))
1026}
1027
1028pub fn parse_ws_position_status_report(
1037 position: &LighterPosition,
1038 instrument: &InstrumentAny,
1039 account_id: AccountId,
1040 ts_event: UnixNanos,
1041 ts_init: UnixNanos,
1042) -> anyhow::Result<PositionStatusReport> {
1043 let quantity = quantity_from_decimal(position.position, instrument.size_precision())?;
1044 let position_side = if quantity.is_zero() {
1045 PositionSide::Flat
1046 } else if position.sign < 0 {
1047 PositionSide::Short
1048 } else {
1049 PositionSide::Long
1050 };
1051
1052 let avg_px_open = if position_side == PositionSide::Flat {
1053 None
1054 } else {
1055 Some(position.avg_entry_price)
1056 };
1057
1058 Ok(PositionStatusReport::new(
1059 account_id,
1060 instrument.id(),
1061 position_side,
1062 quantity,
1063 ts_event,
1064 ts_init,
1065 Some(UUID4::new()),
1066 None, avg_px_open,
1068 ))
1069}
1070
1071pub fn account_balance_from_lighter_asset(asset: &LighterAsset) -> anyhow::Result<AccountBalance> {
1097 let currency = Currency::get_or_create_crypto(asset.symbol);
1098 let total = asset.balance + asset.margin_balance;
1099 let locked = asset.locked_balance;
1100 AccountBalance::from_total_and_locked(total, locked, currency)
1101 .context("failed to construct Lighter account balance")
1102}
1103
1104pub fn margin_balance_from_user_stats(stats: &LighterUserStats) -> anyhow::Result<MarginBalance> {
1128 margin_balance_from_user_stats_with_currency(stats, Currency::get_or_create_crypto("USDC"))
1129}
1130
1131pub fn margin_balance_from_user_stats_with_currency(
1137 stats: &LighterUserStats,
1138 settlement_currency: Currency,
1139) -> anyhow::Result<MarginBalance> {
1140 let initial_dec = (stats.collateral - stats.available_balance).max(Decimal::ZERO);
1141 let initial = Money::from_decimal(initial_dec, settlement_currency)
1142 .map_err(|e| anyhow::anyhow!("failed to construct initial margin: {e}"))?;
1143 let maintenance = Money::from_decimal(Decimal::ZERO, settlement_currency)
1144 .map_err(|e| anyhow::anyhow!("failed to construct maintenance margin: {e}"))?;
1145 Ok(MarginBalance::new(initial, maintenance, None))
1146}
1147
1148#[must_use]
1155pub fn build_unified_account_state(
1156 balances: Vec<AccountBalance>,
1157 margin: Option<MarginBalance>,
1158 account_id: AccountId,
1159 ts_event: UnixNanos,
1160 ts_init: UnixNanos,
1161) -> AccountState {
1162 let margins = margin.map(|m| vec![m]).unwrap_or_default();
1163 AccountState::new(
1164 account_id,
1165 AccountType::Margin,
1166 balances,
1167 margins,
1168 true,
1169 UUID4::new(),
1170 ts_event,
1171 ts_init,
1172 None,
1173 )
1174}
1175
1176const UNIX_TIMESTAMP_SECONDS_MAX: i64 = 9_999_999_999;
1180
1181fn parse_optional_order_timestamp(timestamp: i64) -> anyhow::Result<UnixNanos> {
1182 if timestamp <= 0 {
1183 return Ok(UnixNanos::default());
1184 }
1185
1186 let millis = if timestamp <= UNIX_TIMESTAMP_SECONDS_MAX {
1187 timestamp * 1_000
1188 } else {
1189 timestamp
1190 };
1191
1192 parse_millis_to_nanos(millis as u64)
1193}
1194
1195fn parse_optional_price(value: Decimal, precision: u8) -> anyhow::Result<Option<Price>> {
1196 if value.is_zero() {
1197 return Ok(None);
1198 }
1199 Price::from_decimal_dp(value, precision)
1200 .map(Some)
1201 .map_err(|e| anyhow::anyhow!("invalid price `{value}` at precision {precision}: {e}"))
1202}
1203
1204fn lighter_fee_to_commission(
1205 fee_ticks: Option<i32>,
1206 currency: Currency,
1207) -> Result<Money, LighterCommissionError> {
1208 let ticks = fee_ticks.unwrap_or(0);
1209 let amount = Decimal::new(i64::from(ticks), FEE_DECIMALS);
1210 Money::from_decimal(amount, currency).map_err(|e| LighterCommissionError::new(e.to_string()))
1211}
1212
1213fn nautilus_order_side(side: LighterOrderSide) -> OrderSide {
1214 match side {
1215 LighterOrderSide::Buy => OrderSide::Buy,
1216 LighterOrderSide::Sell => OrderSide::Sell,
1217 }
1218}
1219
1220fn nautilus_order_type(kind: LighterOrderKind) -> anyhow::Result<OrderType> {
1221 match kind {
1222 LighterOrderKind::Limit => Ok(OrderType::Limit),
1223 LighterOrderKind::Market => Ok(OrderType::Market),
1224 LighterOrderKind::StopLoss => Ok(OrderType::StopMarket),
1225 LighterOrderKind::StopLossLimit => Ok(OrderType::StopLimit),
1226 LighterOrderKind::TakeProfit => Ok(OrderType::MarketIfTouched),
1227 LighterOrderKind::TakeProfitLimit => Ok(OrderType::LimitIfTouched),
1228 LighterOrderKind::Twap | LighterOrderKind::TwapSub | LighterOrderKind::Liquidation => Err(
1229 anyhow::anyhow!("Lighter `{kind:?}` has no Nautilus order-type equivalent",),
1230 ),
1231 }
1232}
1233
1234fn nautilus_time_in_force(
1235 tif: LighterOrderTimeInForce,
1236 order_expiry: i64,
1237) -> (TimeInForce, Option<UnixNanos>) {
1238 match tif {
1239 LighterOrderTimeInForce::ImmediateOrCancel => (TimeInForce::Ioc, None),
1240 LighterOrderTimeInForce::PostOnly | LighterOrderTimeInForce::GoodTillTime => {
1241 if order_expiry > 0 {
1244 match parse_millis_to_nanos(order_expiry as u64) {
1245 Ok(expiry) => (TimeInForce::Gtd, Some(expiry)),
1246 Err(_) => (TimeInForce::Gtc, None),
1247 }
1248 } else {
1249 (TimeInForce::Gtc, None)
1250 }
1251 }
1252 LighterOrderTimeInForce::Unknown => (TimeInForce::Gtc, None),
1253 }
1254}
1255
1256fn nautilus_order_status(status: LighterOrderStatus, filled_qty: &Quantity) -> OrderStatus {
1257 match status {
1258 LighterOrderStatus::InProgress => OrderStatus::Submitted,
1259 LighterOrderStatus::Pending | LighterOrderStatus::Open => {
1260 if filled_qty.is_zero() {
1261 OrderStatus::Accepted
1262 } else {
1263 OrderStatus::PartiallyFilled
1264 }
1265 }
1266 LighterOrderStatus::Filled => OrderStatus::Filled,
1267 LighterOrderStatus::CanceledExpired => OrderStatus::Expired,
1268 LighterOrderStatus::CanceledPostOnly => OrderStatus::Rejected,
1269 LighterOrderStatus::Canceled
1270 | LighterOrderStatus::CanceledReduceOnly
1271 | LighterOrderStatus::CanceledPositionNotAllowed
1272 | LighterOrderStatus::CanceledMarginNotAllowed
1273 | LighterOrderStatus::CanceledTooMuchSlippage
1274 | LighterOrderStatus::CanceledNotEnoughLiquidity
1275 | LighterOrderStatus::CanceledSelfTrade
1276 | LighterOrderStatus::CanceledOco
1277 | LighterOrderStatus::CanceledChild
1278 | LighterOrderStatus::CanceledLiquidation
1279 | LighterOrderStatus::CanceledInvalidBalance => OrderStatus::Canceled,
1280 }
1281}
1282
1283fn venue_order_id_from(str_field: Option<&str>, numeric_fallback: i64) -> VenueOrderId {
1287 match str_field {
1288 Some(s) => VenueOrderId::new(s),
1289 None => VenueOrderId::new(numeric_fallback.to_string()),
1290 }
1291}
1292
1293fn client_order_id_from(str_field: Option<&str>, numeric_fallback: i64) -> Option<ClientOrderId> {
1297 match str_field {
1298 Some(s) if !s.is_empty() && s != "0" => Some(ClientOrderId::new(s)),
1299 None if numeric_fallback != 0 => Some(ClientOrderId::new(numeric_fallback.to_string())),
1300 _ => None,
1301 }
1302}
1303
1304#[cfg(test)]
1305mod tests {
1306 use std::str::FromStr;
1307
1308 use nautilus_model::{
1309 enums::{BarAggregation, PriceType},
1310 identifiers::{InstrumentId, StrategyId, Symbol, Venue},
1311 instruments::CryptoPerpetual,
1312 types::{Price, Quantity, currency::Currency},
1313 };
1314 use rstest::rstest;
1315 use ustr::Ustr;
1316
1317 use super::*;
1318 use crate::{
1319 common::enums::LighterTradeType,
1320 http::models::LighterTrade,
1321 websocket::messages::{LighterMarketStats, LighterSpotMarketStats},
1322 };
1323
1324 fn create_test_instrument() -> InstrumentAny {
1325 create_test_instrument_with_quote(Venue::new("LIGHTER"), Currency::from("USDC"))
1326 }
1327
1328 fn create_test_instrument_with_quote(venue: Venue, quote_currency: Currency) -> InstrumentAny {
1329 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), venue);
1330
1331 InstrumentAny::CryptoPerpetual(
1332 CryptoPerpetual::builder()
1333 .instrument_id(instrument_id)
1334 .raw_symbol(Symbol::new("ETH-PERP"))
1335 .base_currency(Currency::from("ETH"))
1336 .quote_currency(quote_currency)
1337 .settlement_currency(quote_currency)
1338 .is_inverse(false)
1339 .price_precision(2)
1340 .size_precision(4)
1341 .price_increment(Price::from("0.01"))
1342 .size_increment(Quantity::from("0.0001"))
1343 .ts_event(UnixNanos::default())
1344 .ts_init(UnixNanos::default())
1345 .build()
1346 .unwrap(),
1347 )
1348 }
1349
1350 fn stub_book() -> LighterWsOrderBook {
1351 LighterWsOrderBook {
1352 code: 0,
1353 asks: vec![LighterPriceLevel {
1354 price: Decimal::from_str("2064.54").unwrap(),
1355 size: Decimal::from_str("0.3285").unwrap(),
1356 }],
1357 bids: vec![LighterPriceLevel {
1358 price: Decimal::from_str("2064.30").unwrap(),
1359 size: Decimal::from_str("1.0392").unwrap(),
1360 }],
1361 offset: 1558300,
1362 nonce: 9182390020,
1363 last_updated_at: 1774884082309144,
1364 begin_nonce: 9182389998,
1365 }
1366 }
1367
1368 fn stub_market_stats() -> LighterMarketStats {
1369 LighterMarketStats {
1370 symbol: Ustr::from("ETH"),
1371 market_id: 0,
1372 index_price: Decimal::from_str("2064.48").unwrap(),
1373 mark_price: Decimal::from_str("2064.47").unwrap(),
1374 mid_price: Decimal::from_str("2064.39").unwrap(),
1375 open_interest: Decimal::from_str("27250.8411").unwrap(),
1376 open_interest_limit: Decimal::from_str("50000.0000").unwrap(),
1377 funding_clamp_small: Decimal::from_str("0.0001").unwrap(),
1378 funding_clamp_big: Decimal::from_str("0.0002").unwrap(),
1379 last_trade_price: Decimal::from_str("2064.50").unwrap(),
1380 current_funding_rate: Decimal::from_str("0.000001").unwrap(),
1381 funding_rate: Decimal::from_str("0.000002").unwrap(),
1382 funding_timestamp: 1_774_879_200_000,
1383 daily_base_token_volume: Decimal::new(1_999_586_931, 4),
1384 daily_quote_token_volume: Decimal::new(471_193_598_847_246, 6),
1385 daily_price_low: Decimal::new(231_181, 2),
1386 daily_price_high: Decimal::new(2_398, 0),
1387 daily_price_change: Decimal::new(16_854_147_780_232_130, 17),
1388 }
1389 }
1390
1391 fn stub_spot_market_stats() -> LighterSpotMarketStats {
1392 LighterSpotMarketStats {
1393 symbol: Ustr::from("ETH"),
1394 market_id: 2048,
1395 index_price: Decimal::from_str("1.000000").unwrap(),
1396 mid_price: Decimal::from_str("1.000001").unwrap(),
1397 last_trade_price: Decimal::from_str("1.000002").unwrap(),
1398 daily_base_token_volume: Decimal::from(1000),
1399 daily_quote_token_volume: Decimal::new(10_001, 1),
1400 daily_price_low: Decimal::new(999_999, 6),
1401 daily_price_high: Decimal::new(1_000_002, 6),
1402 daily_price_change: Decimal::new(1, 6),
1403 }
1404 }
1405
1406 #[rstest]
1407 fn test_parse_ws_order_book_deltas_snapshot() {
1408 let instrument = create_test_instrument();
1409 let ts_init = UnixNanos::from(1);
1410
1411 let deltas =
1412 parse_ws_order_book_deltas(&stub_book(), &instrument, 1774884082326, true, ts_init)
1413 .unwrap();
1414
1415 assert_eq!(deltas.deltas.len(), 3);
1416 assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1417 assert_eq!(deltas.deltas[1].action, BookAction::Add);
1418 assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
1419 assert_eq!(deltas.deltas[1].order.price, Price::from("2064.30"));
1420 assert_eq!(deltas.deltas[1].order.size, Quantity::from("1.0392"));
1421 assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell.into());
1422 assert_eq!(deltas.deltas[2].order.price, Price::from("2064.54"));
1423 assert_eq!(deltas.deltas[2].order.size, Quantity::from("0.3285"));
1424 assert_eq!(deltas.deltas[0].sequence, 9_182_390_020);
1425 assert_eq!(deltas.deltas[1].sequence, 9_182_390_020);
1426 assert_eq!(deltas.deltas[2].sequence, 9_182_390_020);
1427 assert_eq!(deltas.sequence, 9_182_390_020);
1428 assert_eq!(
1429 deltas.deltas[2].flags & RecordFlag::F_LAST as u8,
1430 RecordFlag::F_LAST as u8,
1431 );
1432 }
1433
1434 #[rstest]
1435 fn test_parse_ws_order_book_deltas_update_delete_zero_size() {
1436 let instrument = create_test_instrument();
1437 let mut book = stub_book();
1438 book.asks[0].size = Decimal::ZERO;
1439
1440 let deltas = parse_ws_order_book_deltas(
1441 &book,
1442 &instrument,
1443 1774884082326,
1444 false,
1445 UnixNanos::from(1),
1446 )
1447 .unwrap();
1448
1449 assert_eq!(deltas.deltas.len(), 2);
1450 assert_eq!(deltas.deltas[0].action, BookAction::Update);
1451 assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy.into());
1452 assert_eq!(deltas.deltas[0].order.price, Price::from("2064.30"));
1453 assert_eq!(deltas.deltas[1].action, BookAction::Delete);
1454 assert_eq!(deltas.deltas[1].order.side, OrderSide::Sell.into());
1455 assert_eq!(deltas.deltas[1].order.price, Price::from("2064.54"));
1456 }
1457
1458 #[rstest]
1459 fn test_parse_ws_order_book_deltas_rejects_negative_nonce() {
1460 let instrument = create_test_instrument();
1461 let mut book = stub_book();
1462 book.nonce = -1;
1463
1464 let err = parse_ws_order_book_deltas(
1465 &book,
1466 &instrument,
1467 1774884082326,
1468 false,
1469 UnixNanos::from(1),
1470 )
1471 .unwrap_err();
1472
1473 assert!(err.to_string().contains("negative Lighter book nonce"));
1474 }
1475
1476 #[rstest]
1477 fn test_parse_ws_order_book_deltas_rejects_empty_update() {
1478 let instrument = create_test_instrument();
1479 let mut book = stub_book();
1480 book.asks.clear();
1481 book.bids.clear();
1482
1483 let err = parse_ws_order_book_deltas(
1484 &book,
1485 &instrument,
1486 1774884082326,
1487 false,
1488 UnixNanos::from(1),
1489 )
1490 .unwrap_err();
1491
1492 assert!(
1493 err.to_string()
1494 .contains("empty Lighter WebSocket order book update")
1495 );
1496 }
1497
1498 #[rstest]
1499 fn test_parse_ws_order_book_deltas_rejects_zero_size_snapshot_level() {
1500 let instrument = create_test_instrument();
1501 let mut book = stub_book();
1502 book.bids[0].size = Decimal::ZERO;
1503
1504 let err =
1505 parse_ws_order_book_deltas(&book, &instrument, 1774884082326, true, UnixNanos::from(1))
1506 .unwrap_err();
1507
1508 assert!(
1509 err.to_string()
1510 .contains("failed to construct Lighter WebSocket book delta")
1511 );
1512 }
1513
1514 #[rstest]
1515 fn test_parse_ws_quote_tick() {
1516 let instrument = create_test_instrument();
1517 let ticker = LighterTicker {
1518 s: Ustr::from("ETH"),
1519 a: LighterPriceLevel {
1520 price: Decimal::from_str("2064.48").unwrap(),
1521 size: Decimal::from_str("0.4950").unwrap(),
1522 },
1523 b: LighterPriceLevel {
1524 price: Decimal::from_str("2064.30").unwrap(),
1525 size: Decimal::from_str("1.0392").unwrap(),
1526 },
1527 last_updated_at: 1774883844921166,
1528 };
1529
1530 let quote = parse_ws_quote_tick(&ticker, &instrument, 1774883844933, UnixNanos::from(1))
1531 .unwrap()
1532 .expect("two-sided ticker yields a quote");
1533
1534 assert_eq!(quote.instrument_id, instrument.id());
1535 assert_eq!(quote.bid_price, Price::from("2064.30"));
1536 assert_eq!(quote.ask_price, Price::from("2064.48"));
1537 assert_eq!(quote.bid_size, Quantity::from("1.0392"));
1538 assert_eq!(quote.ask_size, Quantity::from("0.4950"));
1539 assert_eq!(quote.ts_event, UnixNanos::from(1_774_883_844_933_000_000),);
1540 }
1541
1542 #[rstest]
1543 fn test_parse_ws_quote_tick_skips_one_sided_book() {
1544 let instrument = create_test_instrument();
1545 let ticker = LighterTicker {
1546 s: Ustr::from("ETH"),
1547 a: LighterPriceLevel {
1548 price: Decimal::from_str("2064.48").unwrap(),
1549 size: Decimal::from_str("0.4950").unwrap(),
1550 },
1551 b: LighterPriceLevel {
1552 price: Decimal::ZERO,
1555 size: Decimal::ZERO,
1556 },
1557 last_updated_at: 1774883844921166,
1558 };
1559
1560 let result =
1561 parse_ws_quote_tick(&ticker, &instrument, 1774883844933, UnixNanos::from(1)).unwrap();
1562
1563 assert!(result.is_none());
1564 }
1565
1566 #[rstest]
1567 fn test_parse_ws_quote_tick_rejects_invalid_price() {
1568 let payload = serde_json::json!({
1572 "s": "ETH",
1573 "a": {"price": "not-a-price", "size": "0.4950"},
1574 "b": {"price": "2064.30", "size": "1.0392"},
1575 "last_updated_at": 1774883844921166u64,
1576 });
1577
1578 let err = serde_json::from_value::<LighterTicker>(payload).unwrap_err();
1579 assert!(err.to_string().to_lowercase().contains("decimal"));
1580 }
1581
1582 #[rstest]
1583 fn test_parse_ws_mark_price_update() {
1584 let instrument = create_test_instrument();
1585
1586 let update = parse_ws_mark_price_update(
1587 &stub_market_stats(),
1588 &instrument,
1589 1_774_883_844_933,
1590 UnixNanos::from(1),
1591 )
1592 .unwrap();
1593
1594 assert_eq!(update.instrument_id, instrument.id());
1595 assert_eq!(update.value, Price::from("2064.47"));
1596 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1597 assert_eq!(update.ts_init, UnixNanos::from(1));
1598 }
1599
1600 #[rstest]
1601 fn test_parse_ws_index_price_update() {
1602 let instrument = create_test_instrument();
1603
1604 let update = parse_ws_index_price_update(
1605 &stub_market_stats(),
1606 &instrument,
1607 1_774_883_844_933,
1608 UnixNanos::from(1),
1609 )
1610 .unwrap();
1611
1612 assert_eq!(update.instrument_id, instrument.id());
1613 assert_eq!(update.value, Price::from("2064.48"));
1614 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1615 }
1616
1617 #[rstest]
1618 fn test_parse_ws_spot_index_price_update() {
1619 let instrument = create_test_instrument();
1620
1621 let update = parse_ws_spot_index_price_update(
1622 &stub_spot_market_stats(),
1623 &instrument,
1624 1_774_883_844_933,
1625 UnixNanos::from(1),
1626 )
1627 .unwrap();
1628
1629 assert_eq!(update.instrument_id, instrument.id());
1630 assert_eq!(update.value, Price::from("1.00"));
1631 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1632 }
1633
1634 #[rstest]
1635 fn test_parse_ws_funding_rate_update_omits_last_payment_timestamp() {
1636 let instrument = create_test_instrument();
1637 let stats = stub_market_stats();
1638 let ts_init = UnixNanos::from(1_774_883_900_000_000_000);
1639
1640 let update =
1641 parse_ws_funding_rate_update(&stats, &instrument, 1_774_883_844_933, ts_init).unwrap();
1642
1643 assert_eq!(stats.funding_timestamp, 1_774_879_200_000);
1644 assert_eq!(update.instrument_id, instrument.id());
1645 assert_eq!(update.rate, Decimal::from_str("0.000001").unwrap());
1646 assert_eq!(update.interval, None);
1647 assert_eq!(update.next_funding_ns, None);
1648 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1649 assert_eq!(update.ts_init, ts_init);
1650 }
1651
1652 #[rstest]
1659 fn test_parse_ws_mark_price_update_reads_mark_field_only() {
1660 let instrument = create_test_instrument();
1661 let mut stats = stub_market_stats();
1662 let sentinel = Decimal::from_str("1234.56").unwrap();
1663 let other = Decimal::from_str("9999.99").unwrap();
1664 stats.mark_price = sentinel;
1665 stats.index_price = other;
1666 stats.mid_price = other;
1667 stats.last_trade_price = other;
1668
1669 let update =
1670 parse_ws_mark_price_update(&stats, &instrument, 1_774_883_844_933, UnixNanos::from(1))
1671 .unwrap();
1672
1673 assert_eq!(update.value, Price::from("1234.56"));
1674 }
1675
1676 #[rstest]
1677 fn test_parse_ws_index_price_update_reads_index_field_only() {
1678 let instrument = create_test_instrument();
1679 let mut stats = stub_market_stats();
1680 let sentinel = Decimal::from_str("1234.56").unwrap();
1681 let other = Decimal::from_str("9999.99").unwrap();
1682 stats.index_price = sentinel;
1683 stats.mark_price = other;
1684 stats.mid_price = other;
1685 stats.last_trade_price = other;
1686
1687 let update =
1688 parse_ws_index_price_update(&stats, &instrument, 1_774_883_844_933, UnixNanos::from(1))
1689 .unwrap();
1690
1691 assert_eq!(update.value, Price::from("1234.56"));
1692 }
1693
1694 #[rstest]
1695 fn test_parse_ws_funding_rate_update_reads_current_funding_rate_field_only() {
1696 let instrument = create_test_instrument();
1697 let mut stats = stub_market_stats();
1698 let sentinel = Decimal::from_str("0.0000123").unwrap();
1702 let other = Decimal::from_str("0.9999").unwrap();
1703 stats.current_funding_rate = sentinel;
1704 stats.funding_rate = other;
1705
1706 let update = parse_ws_funding_rate_update(
1707 &stats,
1708 &instrument,
1709 1_774_883_844_933,
1710 UnixNanos::from(1),
1711 )
1712 .unwrap();
1713
1714 assert_eq!(update.rate, sentinel);
1715 }
1716
1717 #[rstest]
1718 fn test_parse_ws_order_book_depth_preserves_sparse_levels() {
1719 let instrument = create_test_instrument();
1720 let depth =
1721 parse_ws_order_book_depth(&stub_book(), &instrument, 1774884082326, UnixNanos::from(1))
1722 .unwrap();
1723
1724 assert_eq!(depth.instrument_id, instrument.id());
1725 assert_eq!(depth.bids.len(), 1);
1726 assert_eq!(depth.asks.len(), 1);
1727 assert_eq!(depth.bids[0].price, Price::from("2064.30"));
1728 assert_eq!(depth.bids[0].size, Quantity::from("1.0392"));
1729 assert_eq!(depth.bids[0].side, OrderSide::Buy.into());
1730 assert_eq!(depth.bids[0].order_id, 0);
1731 assert_eq!(depth.asks[0].price, Price::from("2064.54"));
1732 assert_eq!(depth.asks[0].size, Quantity::from("0.3285"));
1733 assert_eq!(depth.asks[0].side, OrderSide::Sell.into());
1734 assert_eq!(depth.asks[0].order_id, 0);
1735 assert_eq!(depth.sequence, 9_182_390_020);
1736 assert_eq!(depth.bid_counts.as_slice(), &[1]);
1737 assert_eq!(depth.ask_counts.as_slice(), &[1]);
1738 assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
1739 assert_eq!(depth.ts_event, UnixNanos::from(1_774_884_082_326_000_000));
1740 assert_eq!(depth.ts_init, UnixNanos::from(1));
1741 }
1742
1743 #[rstest]
1744 fn test_parse_ws_trade_tick_delegates_trade_parser() {
1745 let instrument = create_test_instrument();
1746 let trade = LighterTrade {
1747 trade_id: 16164557907,
1748 trade_id_str: Some("16164557907".to_string()),
1749 tx_hash: "019f2b9c".to_string(),
1750 trade_type: LighterTradeType::Trade,
1751 market_id: 0,
1752 size: Decimal::from_str("0.1336").unwrap(),
1753 price: Decimal::from_str("2181.83").unwrap(),
1754 usd_amount: Decimal::from_str("291.492488").unwrap(),
1755 ask_id: 281476612587355,
1756 ask_id_str: Some("281476612587355".to_string()),
1757 bid_id: 562948334068259,
1758 bid_id_str: Some("562948334068259".to_string()),
1759 ask_client_id: 363283,
1760 ask_client_id_str: Some("363283".to_string()),
1761 bid_client_id: 23004521241,
1762 bid_client_id_str: Some("23004521241".to_string()),
1763 ask_account_id: 57890,
1764 bid_account_id: 317068,
1765 is_maker_ask: false,
1766 block_height: 198321831,
1767 timestamp: 1773854156654,
1768 taker_fee: Some(196),
1769 taker_position_size_before: None,
1770 taker_entry_quote_before: None,
1771 taker_initial_margin_fraction_before: None,
1772 taker_position_sign_changed: None,
1773 maker_fee: Some(28),
1774 maker_position_size_before: None,
1775 maker_entry_quote_before: None,
1776 maker_initial_margin_fraction_before: None,
1777 maker_position_sign_changed: None,
1778 transaction_time: 1773854156686065,
1779 ask_account_pnl: None,
1780 bid_account_pnl: None,
1781 };
1782
1783 let tick = parse_ws_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap();
1784
1785 assert_eq!(tick.trade_id.to_string(), "16164557907");
1786 assert_eq!(tick.price, Price::from("2181.83"));
1787 }
1788
1789 fn account_id() -> AccountId {
1790 AccountId::from("LIGHTER-1234")
1791 }
1792
1793 fn stub_order(status: LighterOrderStatus) -> LighterOrder {
1794 LighterOrder {
1795 order_index: 281476929510110,
1796 client_order_index: 42,
1797 order_id: "281476929510110".to_string(),
1798 client_order_id: "42".to_string(),
1799 market_index: 0,
1800 owner_account_index: 1234,
1801 initial_base_amount: Decimal::from_str("0.0050").unwrap(),
1802 price: Decimal::from_str("2352.74").unwrap(),
1803 nonce: 9182390020,
1804 remaining_base_amount: Decimal::from_str("0.0030").unwrap(),
1805 is_ask: true,
1806 base_size: 50,
1807 base_price: 235274,
1808 filled_base_amount: Decimal::from_str("0.0020").unwrap(),
1809 filled_quote_amount: Decimal::from_str("4.705480").unwrap(),
1810 side: Some(LighterOrderSide::Sell),
1811 order_type: LighterOrderKind::Limit,
1812 time_in_force: LighterOrderTimeInForce::GoodTillTime,
1813 reduce_only: false,
1814 trigger_price: Decimal::ZERO,
1815 order_expiry: 1_780_360_584_479,
1816 status,
1817 trigger_status: LighterTriggerStatus::Na,
1818 trigger_time: 0,
1819 parent_order_index: 0,
1820 parent_order_id: "0".to_string(),
1821 to_trigger_order_id_0: "0".to_string(),
1822 to_trigger_order_id_1: "0".to_string(),
1823 to_cancel_order_id_0: "0".to_string(),
1824 integrator_fee_collector_index: "0".to_string(),
1825 integrator_taker_fee: Decimal::ZERO,
1826 integrator_maker_fee: Decimal::ZERO,
1827 block_height: 227_535_532,
1828 timestamp: 1_777_941_383_576,
1829 created_at: 1_777_941_383_576,
1830 updated_at: 1_777_941_383_900,
1831 transaction_time: 1_777_941_383_576_735,
1832 }
1833 }
1834
1835 fn stub_account_trade(
1836 account_index: i64,
1837 is_maker_ask: bool,
1838 user_is_bidder: bool,
1839 ) -> LighterTrade {
1840 LighterTrade {
1841 trade_id: 19_209_006_902,
1842 trade_id_str: Some("19209006902".to_string()),
1843 tx_hash: "000000128b1ee814".to_string(),
1844 trade_type: LighterTradeType::Trade,
1845 market_id: 0,
1846 size: Decimal::from_str("0.1336").unwrap(),
1847 price: Decimal::from_str("2352.73").unwrap(),
1848 usd_amount: Decimal::from_str("314.324728").unwrap(),
1849 ask_id: 281_476_929_510_102,
1850 ask_id_str: Some("281476929510102".to_string()),
1851 bid_id: 562_947_905_631_053,
1852 bid_id_str: Some("562947905631053".to_string()),
1853 ask_client_id: 0,
1854 ask_client_id_str: Some("0".to_string()),
1855 bid_client_id: 7_001_011_966,
1856 bid_client_id_str: Some("7001011966".to_string()),
1857 ask_account_id: if user_is_bidder {
1858 91_249
1859 } else {
1860 account_index
1861 },
1862 bid_account_id: if user_is_bidder {
1863 account_index
1864 } else {
1865 91_249
1866 },
1867 is_maker_ask,
1868 block_height: 227_535_535,
1869 timestamp: 1_777_941_384_181,
1870 taker_fee: Some(196),
1871 taker_position_size_before: None,
1872 taker_entry_quote_before: None,
1873 taker_initial_margin_fraction_before: None,
1874 taker_position_sign_changed: None,
1875 maker_fee: Some(28),
1876 maker_position_size_before: None,
1877 maker_entry_quote_before: None,
1878 maker_initial_margin_fraction_before: None,
1879 maker_position_sign_changed: None,
1880 transaction_time: 1_777_941_384_181_586,
1881 ask_account_pnl: None,
1882 bid_account_pnl: None,
1883 }
1884 }
1885
1886 #[rstest]
1887 fn test_parse_ws_order_status_report_partial_fill_promotes_status() {
1888 let instrument = create_test_instrument();
1889 let order = stub_order(LighterOrderStatus::Open);
1890
1891 let report =
1892 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1893 .unwrap();
1894
1895 assert_eq!(report.venue_order_id.to_string(), "281476929510110");
1896 assert_eq!(report.client_order_id.unwrap().to_string(), "42");
1897 assert_eq!(report.order_side, OrderSide::Sell.into());
1898 assert_eq!(report.order_type, OrderType::Limit);
1899 assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1901 assert_eq!(report.filled_qty, Quantity::from("0.0020"));
1902 assert_eq!(report.quantity, Quantity::from("0.0050"));
1903 assert_eq!(report.price, Some(Price::from("2352.74")));
1904 assert_eq!(report.trigger_price, None);
1905 assert_eq!(report.time_in_force, TimeInForce::Gtd);
1906 assert!(report.expire_time.is_some());
1907 assert_eq!(report.ts_init, UnixNanos::from(7));
1908 assert_eq!(
1909 report.ts_accepted,
1910 UnixNanos::from(1_777_941_383_576_000_000),
1911 );
1912 assert_eq!(report.ts_last, UnixNanos::from(1_777_941_383_900_000_000));
1913 }
1914
1915 #[rstest]
1916 fn test_parse_ws_order_status_report_normalizes_second_scale_timestamps() {
1917 let instrument = create_test_instrument();
1918 let mut order = stub_order(LighterOrderStatus::Open);
1921 order.timestamp = 1_777_941_383;
1922 order.created_at = 1_777_941_383;
1923 order.updated_at = 1_777_941_383;
1924
1925 let report =
1926 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1927 .unwrap();
1928
1929 assert_eq!(
1930 report.ts_accepted,
1931 UnixNanos::from(1_777_941_383_000_000_000),
1932 );
1933 assert_eq!(report.ts_last, UnixNanos::from(1_777_941_383_000_000_000),);
1934 }
1935
1936 #[rstest]
1937 fn test_parse_ws_order_status_report_pending_is_acknowledged() {
1938 let instrument = create_test_instrument();
1939 let mut order = stub_order(LighterOrderStatus::Pending);
1940 order.filled_base_amount = Decimal::ZERO;
1941
1942 let report =
1943 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1944 .unwrap();
1945
1946 assert_eq!(report.order_status, OrderStatus::Accepted);
1947 }
1948
1949 #[rstest]
1950 fn test_parse_ws_order_status_report_post_only_cancel_is_rejected() {
1951 let instrument = create_test_instrument();
1952 let order = stub_order(LighterOrderStatus::CanceledPostOnly);
1953
1954 let report =
1955 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1956 .unwrap();
1957
1958 assert_eq!(report.order_status, OrderStatus::Rejected);
1959 assert_eq!(report.cancel_reason.as_deref(), Some("post-only"));
1960 }
1961
1962 #[rstest]
1963 fn test_parse_ws_order_status_report_falls_back_to_is_ask() {
1964 let instrument = create_test_instrument();
1965 let mut order = stub_order(LighterOrderStatus::Open);
1966 order.side = None;
1967 order.is_ask = false;
1968
1969 let report =
1970 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1971 .unwrap();
1972
1973 assert_eq!(report.order_side, OrderSide::Buy.into());
1974 }
1975
1976 #[rstest]
1977 fn test_parse_ws_order_status_report_omits_parent_order_id() {
1978 let instrument = create_test_instrument();
1983 let mut order = stub_order(LighterOrderStatus::Open);
1984 order.parent_order_id = "999999".to_string();
1985 order.parent_order_index = 999_999;
1986
1987 let report =
1988 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1989 .unwrap();
1990
1991 assert!(report.parent_order_id.is_none());
1992 assert_eq!(report.contingency_type, None);
1993 }
1994
1995 #[rstest]
1996 #[case::stop_market(LighterOrderKind::StopLoss, OrderType::StopMarket)]
1997 #[case::stop_limit(LighterOrderKind::StopLossLimit, OrderType::StopLimit)]
1998 #[case::market_if_touched(LighterOrderKind::TakeProfit, OrderType::MarketIfTouched)]
1999 #[case::limit_if_touched(LighterOrderKind::TakeProfitLimit, OrderType::LimitIfTouched)]
2000 fn test_parse_ws_order_status_report_conditional_sets_default_trigger_type(
2001 #[case] lighter_order_type: LighterOrderKind,
2002 #[case] expected_order_type: OrderType,
2003 ) {
2004 let instrument = create_test_instrument();
2005 let mut order = stub_order(LighterOrderStatus::Open);
2006 order.order_type = lighter_order_type;
2007 order.trigger_price = Decimal::from_str("2200.00").unwrap();
2008
2009 let report =
2010 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2011 .unwrap();
2012
2013 assert_eq!(report.order_type, expected_order_type);
2014 assert_eq!(report.trigger_price, Some(Price::from("2200.00")));
2015 assert_eq!(report.trigger_type, Some(TriggerType::Default));
2016 }
2017
2018 #[rstest]
2019 #[case::ioc(
2020 LighterOrderTimeInForce::ImmediateOrCancel,
2021 0,
2022 TimeInForce::Ioc,
2023 None,
2024 false
2025 )]
2026 #[case::post_only_negative_expiry(
2027 LighterOrderTimeInForce::PostOnly,
2028 -1,
2029 TimeInForce::Gtc,
2030 None,
2031 true
2032 )]
2033 #[case::post_only_zero_expiry(
2034 LighterOrderTimeInForce::PostOnly,
2035 0,
2036 TimeInForce::Gtc,
2037 None,
2038 true
2039 )]
2040 #[case::post_only_positive_expiry(
2041 LighterOrderTimeInForce::PostOnly,
2042 1_780_000_000_000,
2043 TimeInForce::Gtd,
2044 Some(UnixNanos::from(1_780_000_000_000_000_000_u64)),
2045 true
2046 )]
2047 #[case::gtt_negative_expiry(LighterOrderTimeInForce::GoodTillTime, -1, TimeInForce::Gtc, None, false)]
2048 #[case::gtt_zero_expiry(
2049 LighterOrderTimeInForce::GoodTillTime,
2050 0,
2051 TimeInForce::Gtc,
2052 None,
2053 false
2054 )]
2055 #[case::gtt_positive_expiry(
2056 LighterOrderTimeInForce::GoodTillTime,
2057 1_780_000_000_000,
2058 TimeInForce::Gtd,
2059 Some(UnixNanos::from(1_780_000_000_000_000_000_u64)),
2060 false
2061 )]
2062 #[case::unknown(LighterOrderTimeInForce::Unknown, 0, TimeInForce::Gtc, None, false)]
2063 fn test_parse_ws_order_status_report_time_in_force_matrix(
2064 #[case] tif: LighterOrderTimeInForce,
2065 #[case] order_expiry: i64,
2066 #[case] expected_tif: TimeInForce,
2067 #[case] expected_expire_time: Option<UnixNanos>,
2068 #[case] expected_post_only: bool,
2069 ) {
2070 let instrument = create_test_instrument();
2071 let mut order = stub_order(LighterOrderStatus::Open);
2072 order.time_in_force = tif;
2073 order.order_expiry = order_expiry;
2074
2075 let report =
2076 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2077 .unwrap();
2078
2079 assert_eq!(report.time_in_force, expected_tif);
2080 assert_eq!(report.expire_time, expected_expire_time);
2081 assert_eq!(report.post_only, expected_post_only);
2082 }
2083
2084 #[rstest]
2085 #[case::active(LighterOrderStatus::Open, OrderStatus::Accepted)]
2086 #[case::terminal(LighterOrderStatus::Canceled, OrderStatus::Canceled)]
2087 fn test_parse_ws_post_only_expiry_is_consistent_across_statuses(
2088 #[case] status: LighterOrderStatus,
2089 #[case] expected_status: OrderStatus,
2090 ) {
2091 let instrument = create_test_instrument();
2092 let mut order = stub_order(status);
2093 order.time_in_force = LighterOrderTimeInForce::PostOnly;
2094 order.order_expiry = 1_780_000_000_000;
2095 order.filled_base_amount = Decimal::ZERO;
2096
2097 let report =
2098 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2099 .unwrap();
2100
2101 assert_eq!(report.order_status, expected_status);
2102 assert_eq!(report.time_in_force, TimeInForce::Gtd);
2103 assert_eq!(
2104 report.expire_time,
2105 Some(UnixNanos::from(1_780_000_000_000_000_000_u64))
2106 );
2107 assert!(report.post_only);
2108 }
2109
2110 #[rstest]
2111 fn test_parse_ws_order_status_report_rejects_twap() {
2112 let instrument = create_test_instrument();
2113 let mut order = stub_order(LighterOrderStatus::Open);
2114 order.order_type = LighterOrderKind::Twap;
2115
2116 let err =
2117 parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2118 .unwrap_err();
2119
2120 assert!(
2121 err.to_string()
2122 .contains("no Nautilus order-type equivalent")
2123 );
2124 }
2125
2126 #[rstest]
2131 #[case::bidder_maker_ask_is_taker(
2132 true,
2133 true,
2134 OrderSide::Buy,
2135 LiquiditySide::Taker,
2136 "0.000196 USDC"
2137 )]
2138 #[case::asker_maker_ask_is_maker(
2139 false,
2140 true,
2141 OrderSide::Sell,
2142 LiquiditySide::Maker,
2143 "0.000028 USDC"
2144 )]
2145 #[case::bidder_maker_bid_is_maker(
2146 true,
2147 false,
2148 OrderSide::Buy,
2149 LiquiditySide::Maker,
2150 "0.000028 USDC"
2151 )]
2152 #[case::asker_maker_bid_is_taker(
2153 false,
2154 false,
2155 OrderSide::Sell,
2156 LiquiditySide::Taker,
2157 "0.000196 USDC"
2158 )]
2159 fn test_parse_ws_fill_report_liquidity_side_matrix(
2160 #[case] user_is_bidder: bool,
2161 #[case] is_maker_ask: bool,
2162 #[case] expected_side: OrderSide,
2163 #[case] expected_liquidity: LiquiditySide,
2164 #[case] expected_commission: &str,
2165 ) {
2166 let instrument = create_test_instrument();
2167 let trade = stub_account_trade(1234, is_maker_ask, user_is_bidder);
2168
2169 let report =
2170 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(9))
2171 .unwrap()
2172 .expect("user-side fill");
2173
2174 assert_eq!(report.order_side, expected_side);
2175 assert_eq!(report.liquidity_side, expected_liquidity);
2176 assert_eq!(report.last_qty, Quantity::from("0.1336"));
2177 assert_eq!(report.last_px, Price::from("2352.73"));
2178 assert_eq!(report.commission, Money::from(expected_commission));
2179 let expected_voi = if user_is_bidder {
2180 "562947905631053"
2181 } else {
2182 "281476929510102"
2183 };
2184 assert_eq!(report.venue_order_id.to_string(), expected_voi);
2185 }
2186
2187 #[rstest]
2188 fn test_parse_ws_fill_report_skips_other_accounts() {
2189 let instrument = create_test_instrument();
2190 let trade = stub_account_trade(9999, false, true);
2191
2192 let report =
2193 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2194 .unwrap();
2195
2196 assert!(report.is_none());
2197 }
2198
2199 #[rstest]
2204 fn test_parse_ws_fill_report_venue_order_id_falls_back_to_numeric() {
2205 let instrument = create_test_instrument();
2206 let mut trade = stub_account_trade(1234, false, true);
2207 trade.bid_id_str = None;
2208 trade.ask_id_str = None;
2209
2210 let report =
2211 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2212 .unwrap()
2213 .expect("user-side fill");
2214
2215 assert_eq!(report.venue_order_id.to_string(), "562947905631053");
2217 }
2218
2219 #[rstest]
2224 fn test_parse_ws_fill_report_client_order_id_falls_back_to_numeric() {
2225 let instrument = create_test_instrument();
2226 let mut trade = stub_account_trade(1234, false, true);
2227 trade.bid_client_id_str = None;
2228 trade.ask_client_id_str = None;
2229
2230 let report =
2231 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2232 .unwrap()
2233 .expect("user-side fill");
2234
2235 assert_eq!(report.client_order_id.unwrap().to_string(), "7001011966");
2237 }
2238
2239 #[rstest]
2240 fn test_parse_ws_fill_report_client_order_id_absent_when_zero_numeric() {
2241 let instrument = create_test_instrument();
2242 let mut trade = stub_account_trade(1234, true, false);
2244 trade.ask_client_id_str = None;
2245
2246 let report =
2247 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2248 .unwrap()
2249 .expect("user-side fill");
2250
2251 assert!(report.client_order_id.is_none());
2252 }
2253
2254 #[rstest]
2255 fn test_parse_ws_fill_report_client_order_id_absent_when_string_is_zero_sentinel() {
2256 let instrument = create_test_instrument();
2257 let trade = stub_account_trade(1234, true, false);
2259
2260 let report =
2261 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2262 .unwrap()
2263 .expect("user-side fill");
2264
2265 assert!(report.client_order_id.is_none());
2266 }
2267
2268 #[rstest]
2269 fn test_parse_ws_fill_report_handles_missing_fee() {
2270 let instrument = create_test_instrument();
2271 let mut trade = stub_account_trade(1234, true, true);
2272 trade.taker_fee = None;
2273 trade.maker_fee = None;
2274
2275 let report =
2276 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2277 .unwrap()
2278 .expect("user-side fill");
2279
2280 assert_eq!(report.commission, Money::from("0 USDC"));
2281 }
2282
2283 #[rstest]
2284 fn test_parse_ws_fill_report_uses_instrument_quote_currency_for_fee() {
2285 let currency = Currency::USDG();
2286 let instrument =
2287 create_test_instrument_with_quote(Venue::new("LIGHTER_ROBINHOOD"), currency);
2288
2289 let trade = stub_account_trade(1234, true, true);
2290
2291 let report =
2292 parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2293 .unwrap()
2294 .expect("user-side fill");
2295
2296 assert_eq!(report.commission, Money::from("0.000196 USDG"));
2297 }
2298
2299 #[rstest]
2300 fn test_parse_ws_position_status_report_long_position() {
2301 let instrument = create_test_instrument();
2302 let position = LighterPosition {
2303 market_id: 0,
2304 symbol: Ustr::from("ETH"),
2305 initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2306 open_order_count: 1,
2307 pending_order_count: 0,
2308 position_tied_order_count: 0,
2309 sign: 1,
2310 position: Decimal::from_str("1.5000").unwrap(),
2311 avg_entry_price: Decimal::from_str("2350.10").unwrap(),
2312 position_value: Decimal::from_str("3525.15").unwrap(),
2313 unrealized_pnl: Decimal::from_str("3.45").unwrap(),
2314 realized_pnl: Decimal::ZERO,
2315 liquidation_price: Decimal::from_str("1900.00").unwrap(),
2316 total_funding_paid_out: Some(Decimal::from_str("0.05").unwrap()),
2317 margin_mode: 0,
2318 allocated_margin: Decimal::from_str("176.25").unwrap(),
2319 total_discount: Some(Decimal::ZERO),
2320 };
2321
2322 let report = parse_ws_position_status_report(
2323 &position,
2324 &instrument,
2325 account_id(),
2326 UnixNanos::from(50),
2327 UnixNanos::from(50),
2328 )
2329 .unwrap();
2330
2331 assert_eq!(report.position_side, PositionSide::Long);
2332 assert_eq!(report.quantity, Quantity::from("1.5000"));
2333 assert_eq!(report.signed_decimal_qty, Decimal::new(15, 1));
2334 assert_eq!(report.avg_px_open, Some(Decimal::new(235010, 2)));
2335 assert!(report.venue_position_id.is_none());
2336 }
2337
2338 #[rstest]
2339 fn test_parse_ws_position_status_report_short_position() {
2340 let instrument = create_test_instrument();
2341 let position = LighterPosition {
2342 market_id: 0,
2343 symbol: Ustr::from("ETH"),
2344 initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2345 open_order_count: 0,
2346 pending_order_count: 0,
2347 position_tied_order_count: 0,
2348 sign: -1,
2349 position: Decimal::from_str("0.7500").unwrap(),
2350 avg_entry_price: Decimal::from_str("2400.00").unwrap(),
2351 position_value: Decimal::from_str("1800.00").unwrap(),
2352 unrealized_pnl: Decimal::ZERO,
2353 realized_pnl: Decimal::ZERO,
2354 liquidation_price: Decimal::from_str("3000.00").unwrap(),
2355 total_funding_paid_out: None,
2356 margin_mode: 0,
2357 allocated_margin: Decimal::from_str("90.00").unwrap(),
2358 total_discount: None,
2359 };
2360
2361 let report = parse_ws_position_status_report(
2362 &position,
2363 &instrument,
2364 account_id(),
2365 UnixNanos::default(),
2366 UnixNanos::default(),
2367 )
2368 .unwrap();
2369
2370 assert_eq!(report.position_side, PositionSide::Short);
2371 assert_eq!(report.quantity, Quantity::from("0.7500"));
2372 assert_eq!(report.signed_decimal_qty, Decimal::new(-75, 2));
2373 }
2374
2375 #[rstest]
2376 fn test_parse_ws_position_status_report_flat_position() {
2377 let instrument = create_test_instrument();
2378 let position = LighterPosition {
2379 market_id: 0,
2380 symbol: Ustr::from("ETH"),
2381 initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2382 open_order_count: 0,
2383 pending_order_count: 0,
2384 position_tied_order_count: 0,
2385 sign: 0,
2386 position: Decimal::ZERO,
2387 avg_entry_price: Decimal::ZERO,
2388 position_value: Decimal::ZERO,
2389 unrealized_pnl: Decimal::ZERO,
2390 realized_pnl: Decimal::ZERO,
2391 liquidation_price: Decimal::ZERO,
2392 total_funding_paid_out: None,
2393 margin_mode: 0,
2394 allocated_margin: Decimal::ZERO,
2395 total_discount: None,
2396 };
2397
2398 let report = parse_ws_position_status_report(
2399 &position,
2400 &instrument,
2401 account_id(),
2402 UnixNanos::default(),
2403 UnixNanos::default(),
2404 )
2405 .unwrap();
2406
2407 assert_eq!(report.position_side, PositionSide::Flat);
2408 assert!(report.quantity.is_zero());
2409 assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
2410 assert!(report.avg_px_open.is_none());
2411 }
2412
2413 #[rstest]
2414 fn test_account_balance_from_lighter_asset_spot_only() {
2415 let asset = LighterAsset {
2418 symbol: Ustr::from("USDC"),
2419 asset_id: 0,
2420 balance: Decimal::from_str("100.000000").unwrap(),
2421 locked_balance: Decimal::from_str("1.000000").unwrap(),
2422 margin_balance: Decimal::ZERO,
2423 margin_mode: Ustr::default(),
2424 };
2425
2426 let balance = account_balance_from_lighter_asset(&asset).unwrap();
2427 let usdc = Currency::get_or_create_crypto("USDC");
2428 assert_eq!(balance.currency, usdc);
2429 assert_eq!(balance.total, Money::from("100.000000 USDC"));
2430 assert_eq!(balance.locked, Money::from("1.000000 USDC"));
2431 assert_eq!(balance.free, Money::from("99.000000 USDC"));
2432 }
2433
2434 #[rstest]
2435 fn test_account_balance_from_lighter_asset_combines_spot_and_perp() {
2436 let asset = LighterAsset {
2442 symbol: Ustr::from("USDC"),
2443 asset_id: 3,
2444 balance: Decimal::from_str("10.000000").unwrap(),
2445 locked_balance: Decimal::ZERO,
2446 margin_balance: Decimal::from_str("40.000000").unwrap(),
2447 margin_mode: Ustr::from("disabled"),
2448 };
2449
2450 let balance = account_balance_from_lighter_asset(&asset).unwrap();
2451 assert_eq!(balance.total, Money::from("50.000000 USDC"));
2452 assert_eq!(balance.locked, Money::from("0 USDC"));
2453 assert_eq!(balance.free, Money::from("50.000000 USDC"));
2454 }
2455
2456 #[rstest]
2457 fn test_account_balance_from_lighter_asset_locks_only_spot_order_reservation() {
2458 let asset = LighterAsset {
2462 symbol: Ustr::from("USDC"),
2463 asset_id: 3,
2464 balance: Decimal::from_str("10.000000").unwrap(),
2465 locked_balance: Decimal::from_str("1.000000").unwrap(),
2466 margin_balance: Decimal::from_str("40.000000").unwrap(),
2467 margin_mode: Ustr::from("disabled"),
2468 };
2469
2470 let balance = account_balance_from_lighter_asset(&asset).unwrap();
2471 assert_eq!(balance.total, Money::from("50.000000 USDC"));
2472 assert_eq!(balance.locked, Money::from("1.000000 USDC"));
2473 assert_eq!(balance.free, Money::from("49.000000 USDC"));
2474 }
2475
2476 #[rstest]
2477 fn test_margin_balance_from_user_stats_no_positions() {
2478 let stats = LighterUserStats {
2482 account_trading_mode: 0,
2483 available_balance: Decimal::from_str("40.000000").unwrap(),
2484 buying_power: Decimal::ZERO,
2485 collateral: Decimal::from_str("40.000000").unwrap(),
2486 leverage: Decimal::ZERO,
2487 margin_usage: Decimal::ZERO,
2488 portfolio_value: Decimal::from_str("40.000000").unwrap(),
2489 cross_stats: None,
2490 total_stats: None,
2491 };
2492
2493 let margin = margin_balance_from_user_stats(&stats).unwrap();
2494 let usdc = Currency::get_or_create_crypto("USDC");
2495 assert_eq!(margin.currency, usdc);
2496 assert_eq!(margin.initial, Money::from("0 USDC"));
2497 assert_eq!(margin.maintenance, Money::from("0 USDC"));
2498 assert_eq!(margin.instrument_id, None);
2499 }
2500
2501 #[rstest]
2502 fn test_margin_balance_from_user_stats_with_position() {
2503 let stats = LighterUserStats {
2509 account_trading_mode: 0,
2510 available_balance: Decimal::from_str("35.000000").unwrap(),
2511 buying_power: Decimal::from_str("100.000000").unwrap(),
2512 collateral: Decimal::from_str("40.000000").unwrap(),
2513 leverage: Decimal::from_str("5.00").unwrap(),
2514 margin_usage: Decimal::from_str("12.50").unwrap(),
2515 portfolio_value: Decimal::from_str("40.000000").unwrap(),
2516 cross_stats: None,
2517 total_stats: None,
2518 };
2519
2520 let margin = margin_balance_from_user_stats(&stats).unwrap();
2521 assert_eq!(margin.initial, Money::from("5.000000 USDC"));
2522 assert_eq!(margin.maintenance, Money::from("0 USDC"));
2523 }
2524
2525 #[rstest]
2526 fn test_build_unified_account_state_emits_margin_account() {
2527 let asset = LighterAsset {
2528 symbol: Ustr::from("USDC"),
2529 asset_id: 3,
2530 balance: Decimal::from_str("10.000000").unwrap(),
2531 locked_balance: Decimal::ZERO,
2532 margin_balance: Decimal::from_str("40.000000").unwrap(),
2533 margin_mode: Ustr::from("disabled"),
2534 };
2535 let balances = vec![account_balance_from_lighter_asset(&asset).unwrap()];
2536
2537 let stats = LighterUserStats {
2538 account_trading_mode: 0,
2539 available_balance: Decimal::from_str("40.000000").unwrap(),
2540 buying_power: Decimal::ZERO,
2541 collateral: Decimal::from_str("40.000000").unwrap(),
2542 leverage: Decimal::ZERO,
2543 margin_usage: Decimal::ZERO,
2544 portfolio_value: Decimal::from_str("40.000000").unwrap(),
2545 cross_stats: None,
2546 total_stats: None,
2547 };
2548 let margin = margin_balance_from_user_stats(&stats).unwrap();
2549
2550 let state = build_unified_account_state(
2551 balances,
2552 Some(margin),
2553 account_id(),
2554 UnixNanos::from(1_000),
2555 UnixNanos::from(1_001),
2556 );
2557
2558 let usdc = Currency::get_or_create_crypto("USDC");
2559 assert_eq!(state.account_id, account_id());
2560 assert_eq!(state.account_type, AccountType::Margin);
2561 assert_eq!(state.base_currency, None);
2562 assert!(state.is_reported);
2563 assert_eq!(state.balances.len(), 1);
2564 assert_eq!(state.balances[0].total, Money::from("50.000000 USDC"));
2565 assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2566 assert_eq!(state.balances[0].free, Money::from("50.000000 USDC"));
2567 assert_eq!(state.margins.len(), 1);
2568 assert_eq!(state.margins[0].currency, usdc);
2569 assert_eq!(state.margins[0].initial, Money::from("0 USDC"));
2570 assert!(state.margins[0].instrument_id.is_none());
2571 }
2572
2573 fn stub_candle() -> LighterWsCandle {
2574 LighterWsCandle {
2575 t: 1_778_821_440_000,
2576 o: Decimal::new(226_420, 2),
2577 h: Decimal::new(226_434, 2),
2578 l: Decimal::new(226_336, 2),
2579 c: Decimal::new(226_397, 2),
2580 v: Decimal::new(132_237, 4),
2581 quote_volume: Decimal::ZERO,
2582 i: 0,
2583 }
2584 }
2585
2586 #[rstest]
2587 fn test_parse_ws_bar_emits_open_timestamp_and_external_last_spec() {
2588 let instrument = create_test_instrument();
2589 let candle = stub_candle();
2590
2591 let bar = parse_ws_bar(
2592 &instrument,
2593 &candle,
2594 LighterCandleResolution::OneMinute,
2595 UnixNanos::from(99_999),
2596 )
2597 .unwrap();
2598
2599 assert_eq!(bar.bar_type.instrument_id(), instrument.id());
2600 assert_eq!(bar.bar_type.spec().step.get(), 1);
2601 assert_eq!(bar.bar_type.spec().aggregation, BarAggregation::Minute);
2602 assert_eq!(bar.bar_type.spec().price_type, PriceType::Last);
2603 assert_eq!(
2604 bar.bar_type.aggregation_source(),
2605 AggregationSource::External
2606 );
2607 assert_eq!(bar.open, Price::from("2264.20"));
2608 assert_eq!(bar.high, Price::from("2264.34"));
2609 assert_eq!(bar.low, Price::from("2263.36"));
2610 assert_eq!(bar.close, Price::from("2263.97"));
2611 assert_eq!(bar.volume, Quantity::from("13.2237"));
2612 assert_eq!(bar.ts_event, UnixNanos::from(1_778_821_440_000_000_000));
2613 assert_eq!(bar.ts_init, UnixNanos::from(99_999));
2614 }
2615
2616 #[rstest]
2617 fn test_parse_ws_bar_rejects_negative_timestamp() {
2618 let instrument = create_test_instrument();
2619 let mut candle = stub_candle();
2620 candle.t = -1;
2621
2622 let err = parse_ws_bar(
2623 &instrument,
2624 &candle,
2625 LighterCandleResolution::OneMinute,
2626 UnixNanos::default(),
2627 )
2628 .unwrap_err();
2629
2630 assert!(
2631 err.to_string().contains("negative candle timestamp"),
2632 "expected negative-timestamp error, was: {err}",
2633 );
2634 }
2635
2636 fn test_identity() -> OrderIdentity {
2637 test_identity_for(OrderType::Limit)
2638 }
2639
2640 fn test_identity_for(order_type: OrderType) -> OrderIdentity {
2641 OrderIdentity::new(
2642 create_test_instrument().id(),
2643 StrategyId::new("S-TEST"),
2644 OrderSide::Sell,
2645 order_type,
2646 1,
2647 )
2648 }
2649
2650 fn test_trader_id() -> TraderId {
2651 TraderId::new("TRADER-001")
2652 }
2653
2654 fn test_cloid() -> ClientOrderId {
2655 ClientOrderId::new("MY-ORDER-001")
2656 }
2657
2658 #[rstest]
2659 fn parse_lighter_order_event_emits_accepted_on_open() {
2660 let instrument = create_test_instrument();
2661 let mut order = stub_order(LighterOrderStatus::Open);
2662 order.filled_base_amount = Decimal::ZERO;
2663
2664 let event = parse_lighter_order_event(
2665 &order,
2666 &instrument,
2667 &test_identity(),
2668 test_cloid(),
2669 account_id(),
2670 test_trader_id(),
2671 OpenFrameContext {
2672 accepted_already_emitted: false,
2673 triggered_already_emitted: false,
2674 shape_changed: false,
2675 },
2676 UnixNanos::from(7),
2677 )
2678 .unwrap()
2679 .expect("Open with no prior accept emits Accepted");
2680
2681 match event {
2682 ParsedOrderEvent::Accepted(e) => {
2683 assert_eq!(e.client_order_id, test_cloid());
2684 assert_eq!(e.venue_order_id.to_string(), "281476929510110");
2685 }
2686 other => panic!("expected Accepted, was {other:?}"),
2687 }
2688 }
2689
2690 #[rstest]
2691 fn parse_lighter_order_event_emits_accepted_on_pending() {
2692 let instrument = create_test_instrument();
2693 let order = stub_order(LighterOrderStatus::Pending);
2694
2695 let event = parse_lighter_order_event(
2696 &order,
2697 &instrument,
2698 &test_identity(),
2699 test_cloid(),
2700 account_id(),
2701 test_trader_id(),
2702 OpenFrameContext {
2703 accepted_already_emitted: false,
2704 triggered_already_emitted: false,
2705 shape_changed: false,
2706 },
2707 UnixNanos::from(7),
2708 )
2709 .unwrap()
2710 .expect("Pending is venue-acknowledged and emits Accepted");
2711
2712 assert!(matches!(event, ParsedOrderEvent::Accepted(_)));
2713 }
2714
2715 #[rstest]
2716 fn parse_lighter_order_event_emits_updated_on_pending_shape_change() {
2717 let instrument = create_test_instrument();
2718 let mut order = stub_order(LighterOrderStatus::Pending);
2719 order.order_type = LighterOrderKind::StopLossLimit;
2720 order.trigger_price = Decimal::from_str("2300.00").unwrap();
2721
2722 let event = parse_lighter_order_event(
2723 &order,
2724 &instrument,
2725 &test_identity_for(OrderType::StopLimit),
2726 test_cloid(),
2727 account_id(),
2728 test_trader_id(),
2729 OpenFrameContext {
2730 accepted_already_emitted: true,
2731 triggered_already_emitted: false,
2732 shape_changed: true,
2733 },
2734 UnixNanos::from(7),
2735 )
2736 .unwrap()
2737 .expect("modified Pending order emits Updated");
2738
2739 match event {
2740 ParsedOrderEvent::Updated(event) => {
2741 assert_eq!(event.price, Some(Price::from("2352.74")));
2742 assert_eq!(event.trigger_price, Some(Price::from("2300.00")));
2743 }
2744 other => panic!("expected Updated, was {other:?}"),
2745 }
2746 }
2747
2748 #[rstest]
2749 fn parse_lighter_order_event_pending_ready_does_not_emit_triggered() {
2750 let instrument = create_test_instrument();
2751 let mut order = stub_order(LighterOrderStatus::Pending);
2752 order.trigger_status = LighterTriggerStatus::Ready;
2753
2754 let event = parse_lighter_order_event(
2755 &order,
2756 &instrument,
2757 &test_identity_for(OrderType::StopLimit),
2758 test_cloid(),
2759 account_id(),
2760 test_trader_id(),
2761 OpenFrameContext {
2762 accepted_already_emitted: false,
2763 triggered_already_emitted: false,
2764 shape_changed: false,
2765 },
2766 UnixNanos::from(7),
2767 )
2768 .unwrap()
2769 .expect("Pending Ready still emits its acknowledgement");
2770
2771 assert!(matches!(event, ParsedOrderEvent::Accepted(_)));
2772 }
2773
2774 #[rstest]
2775 fn parse_lighter_order_event_emits_updated_only_when_shape_changed() {
2776 let instrument = create_test_instrument();
2782 let order = stub_order(LighterOrderStatus::Open);
2783
2784 let event = parse_lighter_order_event(
2785 &order,
2786 &instrument,
2787 &test_identity(),
2788 test_cloid(),
2789 account_id(),
2790 test_trader_id(),
2791 OpenFrameContext {
2792 accepted_already_emitted: true,
2793 triggered_already_emitted: false,
2794 shape_changed: true,
2795 },
2796 UnixNanos::from(7),
2797 )
2798 .unwrap()
2799 .expect("Open with shape_changed emits Updated");
2800
2801 match event {
2802 ParsedOrderEvent::Updated(e) => {
2803 assert_eq!(e.client_order_id, test_cloid());
2804 assert_eq!(e.quantity, Quantity::from("0.0050"));
2805 assert_eq!(e.price, Some(Price::from("2352.74")));
2806 }
2807 other => panic!("expected Updated, was {other:?}"),
2808 }
2809 }
2810
2811 #[rstest]
2812 fn parse_lighter_order_event_repeat_open_after_accept_is_silent() {
2813 let instrument = create_test_instrument();
2817 let order = stub_order(LighterOrderStatus::Open);
2818
2819 let event = parse_lighter_order_event(
2820 &order,
2821 &instrument,
2822 &test_identity(),
2823 test_cloid(),
2824 account_id(),
2825 test_trader_id(),
2826 OpenFrameContext {
2827 accepted_already_emitted: true,
2828 triggered_already_emitted: false,
2829 shape_changed: false,
2830 },
2831 UnixNanos::from(7),
2832 )
2833 .unwrap();
2834
2835 assert!(event.is_none());
2836 }
2837
2838 #[rstest]
2839 fn parse_lighter_order_event_triggered_dedup_via_open_ctx() {
2840 let instrument = create_test_instrument();
2844 let mut order = stub_order(LighterOrderStatus::Open);
2845 order.trigger_status = LighterTriggerStatus::Ready;
2846
2847 let event = parse_lighter_order_event(
2848 &order,
2849 &instrument,
2850 &test_identity(),
2851 test_cloid(),
2852 account_id(),
2853 test_trader_id(),
2854 OpenFrameContext {
2855 accepted_already_emitted: true,
2856 triggered_already_emitted: true,
2857 shape_changed: false,
2858 },
2859 UnixNanos::from(7),
2860 )
2861 .unwrap();
2862
2863 assert!(event.is_none());
2866 }
2867
2868 #[rstest]
2869 fn parse_lighter_order_event_emits_triggered_after_accept() {
2870 let instrument = create_test_instrument();
2874 let mut order = stub_order(LighterOrderStatus::Open);
2875 order.trigger_status = LighterTriggerStatus::Ready;
2876
2877 let event = parse_lighter_order_event(
2878 &order,
2879 &instrument,
2880 &test_identity(),
2881 test_cloid(),
2882 account_id(),
2883 test_trader_id(),
2884 OpenFrameContext {
2885 accepted_already_emitted: true,
2886 triggered_already_emitted: false,
2887 shape_changed: false,
2888 },
2889 UnixNanos::from(7),
2890 )
2891 .unwrap()
2892 .expect("trigger Ready after accept emits Triggered");
2893
2894 match event {
2895 ParsedOrderEvent::Triggered(_) => {}
2896 other => panic!("expected Triggered, was {other:?}"),
2897 }
2898 }
2899
2900 #[rstest]
2901 fn parse_lighter_order_event_emits_triggered_when_trigger_ready_fresh() {
2902 let instrument = create_test_instrument();
2903 let mut order = stub_order(LighterOrderStatus::Open);
2904 order.filled_base_amount = Decimal::ZERO;
2905 order.trigger_status = LighterTriggerStatus::Ready;
2906
2907 let event_fresh = parse_lighter_order_event(
2908 &order,
2909 &instrument,
2910 &test_identity(),
2911 test_cloid(),
2912 account_id(),
2913 test_trader_id(),
2914 OpenFrameContext {
2915 accepted_already_emitted: false,
2916 triggered_already_emitted: false,
2917 shape_changed: false,
2918 },
2919 UnixNanos::from(7),
2920 )
2921 .unwrap()
2922 .expect("trigger_status=Ready on fresh open emits Triggered");
2923
2924 match event_fresh {
2925 ParsedOrderEvent::Triggered(_) => {}
2926 other => panic!("expected Triggered for fresh ready trigger, was {other:?}"),
2927 }
2928 }
2929
2930 #[rstest]
2931 fn lighter_order_shape_round_trips_values() {
2932 let instrument = create_test_instrument();
2933 let order = stub_order(LighterOrderStatus::Open);
2934
2935 let shape = lighter_order_shape(&order, &instrument, OrderType::Limit).unwrap();
2936
2937 assert_eq!(shape.quantity, Quantity::from("0.0050"));
2938 assert_eq!(shape.price, Some(Price::from("2352.74")));
2939 assert_eq!(shape.trigger_price, None);
2940 }
2941
2942 #[rstest]
2943 fn lighter_order_shape_distinguishes_modified_payload() {
2944 let instrument = create_test_instrument();
2945 let original = stub_order(LighterOrderStatus::Open);
2946 let mut modified = original.clone();
2947 modified.price = Decimal::from_str("2400.00").unwrap();
2948
2949 let shape_original = lighter_order_shape(&original, &instrument, OrderType::Limit).unwrap();
2950 let shape_modified = lighter_order_shape(&modified, &instrument, OrderType::Limit).unwrap();
2951
2952 assert_ne!(shape_original, shape_modified);
2953 }
2954
2955 #[rstest]
2956 #[case::limit(OrderType::Limit, Some(Price::from("2352.74")), None)]
2957 #[case::market(OrderType::Market, None, None)]
2958 #[case::stop_market(OrderType::StopMarket, None, Some(Price::from("2300.00")))]
2959 #[case::stop_limit(
2960 OrderType::StopLimit,
2961 Some(Price::from("2352.74")),
2962 Some(Price::from("2300.00"))
2963 )]
2964 fn lighter_order_shape_projects_fields_for_order_type(
2965 #[case] order_type: OrderType,
2966 #[case] expected_price: Option<Price>,
2967 #[case] expected_trigger: Option<Price>,
2968 ) {
2969 let instrument = create_test_instrument();
2970 let mut order = stub_order(LighterOrderStatus::Pending);
2971 order.trigger_price = Decimal::from_str("2300.00").unwrap();
2972
2973 let shape = lighter_order_shape(&order, &instrument, order_type).unwrap();
2974
2975 assert_eq!(shape.price, expected_price);
2976 assert_eq!(shape.trigger_price, expected_trigger);
2977 }
2978
2979 #[rstest]
2980 fn parse_lighter_order_event_emits_rejected_for_post_only_cancel() {
2981 let instrument = create_test_instrument();
2982 let order = stub_order(LighterOrderStatus::CanceledPostOnly);
2983
2984 let event = parse_lighter_order_event(
2985 &order,
2986 &instrument,
2987 &test_identity(),
2988 test_cloid(),
2989 account_id(),
2990 test_trader_id(),
2991 OpenFrameContext {
2992 accepted_already_emitted: false,
2993 triggered_already_emitted: false,
2994 shape_changed: false,
2995 },
2996 UnixNanos::from(7),
2997 )
2998 .unwrap()
2999 .expect("post-only cancel emits Rejected");
3000
3001 match event {
3002 ParsedOrderEvent::Rejected(e) => {
3003 assert!(e.due_post_only);
3004 assert_eq!(e.reason, "post-only");
3005 }
3006 other => panic!("expected Rejected, was {other:?}"),
3007 }
3008 }
3009
3010 #[rstest]
3011 fn parse_lighter_order_event_emits_expired_for_canceled_expired() {
3012 let instrument = create_test_instrument();
3013 let order = stub_order(LighterOrderStatus::CanceledExpired);
3014
3015 let event = parse_lighter_order_event(
3016 &order,
3017 &instrument,
3018 &test_identity(),
3019 test_cloid(),
3020 account_id(),
3021 test_trader_id(),
3022 OpenFrameContext {
3023 accepted_already_emitted: false,
3024 triggered_already_emitted: false,
3025 shape_changed: false,
3026 },
3027 UnixNanos::from(7),
3028 )
3029 .unwrap()
3030 .expect("canceled-expired emits Expired");
3031
3032 match event {
3033 ParsedOrderEvent::Expired(_) => {}
3034 other => panic!("expected Expired, was {other:?}"),
3035 }
3036 }
3037
3038 #[rstest]
3039 #[case::canceled(LighterOrderStatus::Canceled, None)]
3040 #[case::reduce_only(LighterOrderStatus::CanceledReduceOnly, Some("reduce-only"))]
3041 #[case::self_trade(LighterOrderStatus::CanceledSelfTrade, Some("self-trade"))]
3042 #[case::liquidation(LighterOrderStatus::CanceledLiquidation, Some("liquidation"))]
3043 fn parse_lighter_order_event_emits_canceled_for_other_cancel_variants(
3044 #[case] status: LighterOrderStatus,
3045 #[case] expected_reason: Option<&str>,
3046 ) {
3047 let instrument = create_test_instrument();
3048 let order = stub_order(status);
3049
3050 let event = parse_lighter_order_event(
3051 &order,
3052 &instrument,
3053 &test_identity(),
3054 test_cloid(),
3055 account_id(),
3056 test_trader_id(),
3057 OpenFrameContext {
3058 accepted_already_emitted: false,
3059 triggered_already_emitted: false,
3060 shape_changed: false,
3061 },
3062 UnixNanos::from(7),
3063 )
3064 .unwrap()
3065 .expect("cancel variant emits Canceled");
3066
3067 match event {
3068 ParsedOrderEvent::Canceled(canceled) => {
3069 assert_eq!(
3070 canceled.reason.map(|reason| reason.as_str()),
3071 expected_reason
3072 );
3073 }
3074 other => panic!("expected Canceled, was {other:?}"),
3075 }
3076 }
3077
3078 #[rstest]
3079 #[case::in_progress(LighterOrderStatus::InProgress)]
3080 #[case::filled(LighterOrderStatus::Filled)]
3081 fn parse_lighter_order_event_returns_none_for_silent_statuses(
3082 #[case] status: LighterOrderStatus,
3083 ) {
3084 let instrument = create_test_instrument();
3087 let order = stub_order(status);
3088
3089 let event = parse_lighter_order_event(
3090 &order,
3091 &instrument,
3092 &test_identity(),
3093 test_cloid(),
3094 account_id(),
3095 test_trader_id(),
3096 OpenFrameContext {
3097 accepted_already_emitted: false,
3098 triggered_already_emitted: false,
3099 shape_changed: false,
3100 },
3101 UnixNanos::from(7),
3102 )
3103 .unwrap();
3104
3105 assert!(event.is_none(), "expected None for {status:?}");
3106 }
3107
3108 #[rstest]
3109 fn parse_lighter_order_filled_builds_order_filled_for_account() {
3110 let instrument = create_test_instrument();
3111 let trade = stub_account_trade(1234, true, true);
3112
3113 let filled = parse_lighter_order_filled(
3114 &trade,
3115 &instrument,
3116 &test_identity(),
3117 test_cloid(),
3118 account_id(),
3119 test_trader_id(),
3120 1234,
3121 UnixNanos::from(7),
3122 )
3123 .unwrap()
3124 .expect("trade involving account emits OrderFilled");
3125
3126 assert_eq!(filled.client_order_id, test_cloid());
3127 assert_eq!(filled.order_side, OrderSide::Sell); assert_eq!(filled.order_type, OrderType::Limit);
3129 assert_eq!(filled.last_qty, Quantity::from("0.1336"));
3130 assert_eq!(filled.last_px, Price::from("2352.73"));
3131 assert!(filled.commission.is_some());
3132 }
3133
3134 #[rstest]
3135 fn parse_lighter_order_filled_returns_none_for_other_account() {
3136 let instrument = create_test_instrument();
3137 let trade = stub_account_trade(1234, true, true);
3139
3140 let filled = parse_lighter_order_filled(
3141 &trade,
3142 &instrument,
3143 &test_identity(),
3144 test_cloid(),
3145 account_id(),
3146 test_trader_id(),
3147 99_999, UnixNanos::from(7),
3149 )
3150 .unwrap();
3151
3152 assert!(filled.is_none());
3153 }
3154}