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