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