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