Skip to main content

nautilus_lighter/websocket/
parse.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Parsers from Lighter streaming payloads to Nautilus domain types.
17
18use anyhow::Context;
19use nautilus_core::{UUID4, UnixNanos};
20use nautilus_model::{
21    data::{
22        Bar, BarType, BookOrder, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate,
23        OrderBookDelta, OrderBookDeltas, OrderBookDepth, QuoteTick, TradeTick, depth::DEPTH10_LEN,
24    },
25    enums::{
26        AccountType, AggregationSource, BookAction, LiquiditySide, OrderSide, OrderStatus,
27        OrderType, PositionSide, RecordFlag, TimeInForce, TriggerType,
28    },
29    events::{
30        AccountState, OrderAccepted, OrderCanceled, OrderExpired, OrderFilled, OrderRejected,
31        OrderTriggered, OrderUpdated,
32    },
33    identifiers::{AccountId, ClientOrderId, InstrumentId, TradeId, TraderId, VenueOrderId},
34    instruments::{Instrument, InstrumentAny},
35    orders::{LIMIT_ORDER_TYPES, STOP_ORDER_TYPES, TRIGGERABLE_ORDER_TYPES},
36    reports::{FillReport, OrderStatusReport, PositionStatusReport},
37    types::{AccountBalance, Currency, MarginBalance, Money, Price, Quantity},
38};
39use rust_decimal::Decimal;
40use ustr::Ustr;
41
42use crate::{
43    common::{
44        enums::{
45            LighterCandleResolution, LighterOrderKind, LighterOrderSide, LighterOrderStatus,
46            LighterOrderTimeInForce, LighterTriggerStatus, order_side_from_is_ask,
47        },
48        parse::{parse_millis_to_nanos, price_from_decimal, quantity_from_decimal},
49    },
50    http::{
51        models::{LighterOrder, LighterPriceLevel, LighterTrade},
52        parse::parse_trade_tick,
53    },
54    websocket::{
55        dispatch::{OrderIdentity, OrderShapeSnapshot},
56        messages::{
57            LighterAsset, LighterMarketStats, LighterPosition, LighterSpotMarketStats,
58            LighterTicker, LighterUserStats, LighterWsCandle, LighterWsOrderBook,
59        },
60    },
61};
62
63// Trade fee ticks encode rates: 100 ticks = 1 basis point
64const FEE_DECIMALS: u32 = 6;
65
66#[derive(Debug, thiserror::Error)]
67#[error("failed to construct Lighter commission: {detail}")]
68pub(crate) struct LighterCommissionError {
69    detail: String,
70}
71
72impl LighterCommissionError {
73    pub(crate) fn new(detail: impl Into<String>) -> Self {
74        Self {
75            detail: detail.into(),
76        }
77    }
78}
79
80/// Parses a Lighter trade stream item into a Nautilus [`TradeTick`].
81///
82/// # Errors
83///
84/// Returns an error if the trade cannot be converted into a Nautilus tick.
85pub fn parse_ws_trade_tick(
86    trade: &LighterTrade,
87    instrument: &InstrumentAny,
88    ts_init: UnixNanos,
89) -> anyhow::Result<TradeTick> {
90    parse_trade_tick(trade, instrument, ts_init)
91}
92
93/// Parses a Lighter order book update into Nautilus deltas.
94///
95/// `is_snapshot` must be supplied by the caller because Lighter sends
96/// `subscribed/order_book` for the full book on subscription and
97/// `update/order_book` for incremental level changes afterwards.
98///
99/// # Errors
100///
101/// Returns an error if any price or size cannot be converted.
102pub fn parse_ws_order_book_deltas(
103    book: &LighterWsOrderBook,
104    instrument: &InstrumentAny,
105    timestamp_ms: u64,
106    is_snapshot: bool,
107    ts_init: UnixNanos,
108) -> anyhow::Result<OrderBookDeltas> {
109    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
110    let sequence = u64::try_from(book.nonce).context("negative Lighter book nonce")?;
111    let total_levels = book.bids.len() + book.asks.len();
112
113    anyhow::ensure!(
114        is_snapshot || total_levels > 0,
115        "empty Lighter WebSocket order book update",
116    );
117
118    let mut deltas = Vec::with_capacity(total_levels + usize::from(is_snapshot));
119
120    if is_snapshot {
121        let mut clear = OrderBookDelta::clear(instrument.id(), sequence, ts_event, ts_init);
122        if total_levels == 0 {
123            clear.flags |= RecordFlag::F_LAST as u8;
124        }
125        deltas.push(clear);
126    }
127
128    let mut processed = 0_usize;
129
130    for bid in &book.bids {
131        processed += 1;
132        deltas.push(parse_book_level_delta(
133            bid,
134            instrument,
135            OrderSide::Buy,
136            sequence,
137            ts_event,
138            ts_init,
139            book_flags(is_snapshot, processed, total_levels),
140        )?);
141    }
142
143    for ask in &book.asks {
144        processed += 1;
145        deltas.push(parse_book_level_delta(
146            ask,
147            instrument,
148            OrderSide::Sell,
149            sequence,
150            ts_event,
151            ts_init,
152            book_flags(is_snapshot, processed, total_levels),
153        )?);
154    }
155
156    OrderBookDeltas::new_checked(instrument.id(), deltas)
157        .context("failed to construct OrderBookDeltas from Lighter WebSocket book")
158}
159
160/// Parses a full Lighter order book payload into a Nautilus [`OrderBookDepth`].
161///
162/// Call this only for snapshot or depth payloads that contain the full visible
163/// book. Incremental updates should be parsed as deltas.
164///
165/// # Errors
166///
167/// Returns an error if any price or size cannot be converted.
168pub fn parse_ws_order_book_depth(
169    book: &LighterWsOrderBook,
170    instrument: &InstrumentAny,
171    timestamp_ms: u64,
172    ts_init: UnixNanos,
173) -> anyhow::Result<OrderBookDepth> {
174    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
175    let sequence = u64::try_from(book.nonce).context("negative Lighter book nonce")?;
176    let mut bids = [BookOrder::default(); DEPTH10_LEN];
177    let mut asks = [BookOrder::default(); DEPTH10_LEN];
178    let mut bid_counts = [0_u32; DEPTH10_LEN];
179    let mut ask_counts = [0_u32; DEPTH10_LEN];
180
181    for (idx, level) in book.bids.iter().take(DEPTH10_LEN).enumerate() {
182        bids[idx] = BookOrder::new(
183            OrderSide::Buy,
184            price_from_decimal(level.price, instrument.price_precision())?,
185            quantity_from_decimal(level.size, instrument.size_precision())?,
186            0,
187        );
188        bid_counts[idx] = 1;
189    }
190
191    for bid in bids.iter_mut().skip(book.bids.len().min(DEPTH10_LEN)) {
192        *bid = BookOrder::new(
193            OrderSide::Buy,
194            Price::zero(instrument.price_precision()),
195            Quantity::zero(instrument.size_precision()),
196            0,
197        );
198    }
199
200    for (idx, level) in book.asks.iter().take(DEPTH10_LEN).enumerate() {
201        asks[idx] = BookOrder::new(
202            OrderSide::Sell,
203            price_from_decimal(level.price, instrument.price_precision())?,
204            quantity_from_decimal(level.size, instrument.size_precision())?,
205            0,
206        );
207        ask_counts[idx] = 1;
208    }
209
210    for ask in asks.iter_mut().skip(book.asks.len().min(DEPTH10_LEN)) {
211        *ask = BookOrder::new(
212            OrderSide::Sell,
213            Price::zero(instrument.price_precision()),
214            Quantity::zero(instrument.size_precision()),
215            0,
216        );
217    }
218
219    Ok(OrderBookDepth::new(
220        instrument.id(),
221        bids,
222        asks,
223        bid_counts,
224        ask_counts,
225        RecordFlag::F_SNAPSHOT as u8,
226        sequence,
227        ts_event,
228        ts_init,
229    ))
230}
231
232/// Parses a Lighter ticker stream payload into a Nautilus [`QuoteTick`].
233///
234/// Returns `Ok(None)` if either side carries an empty price/size string,
235/// which Lighter emits when one book side is currently uninhabited (no
236/// resting orders). A one-sided book cannot be expressed as a [`QuoteTick`],
237/// so the frame is skipped rather than rejected.
238///
239/// # Errors
240///
241/// Returns an error if a non-empty bid or ask field cannot be converted.
242pub fn parse_ws_quote_tick(
243    ticker: &LighterTicker,
244    instrument: &InstrumentAny,
245    timestamp_ms: u64,
246    ts_init: UnixNanos,
247) -> anyhow::Result<Option<QuoteTick>> {
248    // Lighter sends zero (or empty string, mapped to zero by the wire deserializer)
249    // for a side that currently has no resting orders; a one-sided book cannot be
250    // expressed as a `QuoteTick`, so the frame is skipped rather than rejected.
251    if ticker.b.price.is_zero()
252        || ticker.b.size.is_zero()
253        || ticker.a.price.is_zero()
254        || ticker.a.size.is_zero()
255    {
256        return Ok(None);
257    }
258
259    let bid_price = price_from_decimal(ticker.b.price, instrument.price_precision())?;
260    let ask_price = price_from_decimal(ticker.a.price, instrument.price_precision())?;
261    let bid_size = quantity_from_decimal(ticker.b.size, instrument.size_precision())?;
262    let ask_size = quantity_from_decimal(ticker.a.size, instrument.size_precision())?;
263    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
264
265    QuoteTick::new_checked(
266        instrument.id(),
267        bid_price,
268        ask_price,
269        bid_size,
270        ask_size,
271        ts_event,
272        ts_init,
273    )
274    .map(Some)
275    .context("failed to construct QuoteTick from Lighter ticker")
276}
277
278/// Parses a Lighter perpetual market-stat update into a mark price update.
279///
280/// # Errors
281///
282/// Returns an error if the mark price or timestamp cannot be converted.
283pub fn parse_ws_mark_price_update(
284    stats: &LighterMarketStats,
285    instrument: &InstrumentAny,
286    timestamp_ms: u64,
287    ts_init: UnixNanos,
288) -> anyhow::Result<MarkPriceUpdate> {
289    build_price_update(
290        instrument,
291        stats.mark_price,
292        timestamp_ms,
293        ts_init,
294        MarkPriceUpdate::new,
295    )
296}
297
298/// Parses a Lighter perpetual market-stat update into an index price update.
299///
300/// # Errors
301///
302/// Returns an error if the index price or timestamp cannot be converted.
303pub fn parse_ws_index_price_update(
304    stats: &LighterMarketStats,
305    instrument: &InstrumentAny,
306    timestamp_ms: u64,
307    ts_init: UnixNanos,
308) -> anyhow::Result<IndexPriceUpdate> {
309    build_price_update(
310        instrument,
311        stats.index_price,
312        timestamp_ms,
313        ts_init,
314        IndexPriceUpdate::new,
315    )
316}
317
318/// Parses a Lighter spot market-stat update into an index price update.
319///
320/// # Errors
321///
322/// Returns an error if the index price or timestamp cannot be converted.
323pub fn parse_ws_spot_index_price_update(
324    stats: &LighterSpotMarketStats,
325    instrument: &InstrumentAny,
326    timestamp_ms: u64,
327    ts_init: UnixNanos,
328) -> anyhow::Result<IndexPriceUpdate> {
329    build_price_update(
330        instrument,
331        stats.index_price,
332        timestamp_ms,
333        ts_init,
334        IndexPriceUpdate::new,
335    )
336}
337
338fn build_price_update<T>(
339    instrument: &InstrumentAny,
340    price: Decimal,
341    timestamp_ms: u64,
342    ts_init: UnixNanos,
343    constructor: impl FnOnce(InstrumentId, Price, UnixNanos, UnixNanos) -> T,
344) -> anyhow::Result<T> {
345    let price = price_from_decimal(price, instrument.price_precision())?;
346    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
347    Ok(constructor(instrument.id(), price, ts_event, ts_init))
348}
349
350/// Parses a Lighter perpetual market-stat update into a funding-rate update.
351///
352/// Lighter exposes `current_funding_rate` as the estimate for the upcoming
353/// payment. The `funding_rate` field is the last completed payment, so it is
354/// not used for the streaming Nautilus update. The accompanying
355/// `funding_timestamp` identifies that completed payment; market stats do not
356/// provide the next settlement time.
357///
358/// # Errors
359///
360/// Returns an error if the event timestamp cannot be converted.
361pub fn parse_ws_funding_rate_update(
362    stats: &LighterMarketStats,
363    instrument: &InstrumentAny,
364    timestamp_ms: u64,
365    ts_init: UnixNanos,
366) -> anyhow::Result<FundingRateUpdate> {
367    let rate = stats.current_funding_rate;
368    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
369    Ok(FundingRateUpdate::new(
370        instrument.id(),
371        rate,
372        None,
373        None,
374        ts_event,
375        ts_init,
376    ))
377}
378
379/// Parses a Lighter WebSocket candle into a Nautilus [`Bar`] with `ts_event` set to the bar open.
380///
381/// # Errors
382///
383/// Returns an error if the OHLCV decimals overflow the instrument's precision, or if the
384/// timestamp cannot be converted.
385pub fn parse_ws_bar(
386    instrument: &InstrumentAny,
387    candle: &LighterWsCandle,
388    resolution: LighterCandleResolution,
389    ts_init: UnixNanos,
390) -> anyhow::Result<Bar> {
391    let price_precision = instrument.price_precision();
392    let size_precision = instrument.size_precision();
393
394    let open = Price::from_decimal_dp(candle.o, price_precision)
395        .map_err(|e| anyhow::anyhow!("invalid candle open: {e}"))?;
396    let high = Price::from_decimal_dp(candle.h, price_precision)
397        .map_err(|e| anyhow::anyhow!("invalid candle high: {e}"))?;
398    let low = Price::from_decimal_dp(candle.l, price_precision)
399        .map_err(|e| anyhow::anyhow!("invalid candle low: {e}"))?;
400    let close = Price::from_decimal_dp(candle.c, price_precision)
401        .map_err(|e| anyhow::anyhow!("invalid candle close: {e}"))?;
402    let volume = Quantity::from_decimal_dp(candle.v, size_precision)
403        .map_err(|e| anyhow::anyhow!("invalid candle volume: {e}"))?;
404
405    let t_ms = u64::try_from(candle.t)
406        .map_err(|_| anyhow::anyhow!("negative candle timestamp: {}", candle.t))?;
407    let ts_event = parse_millis_to_nanos(t_ms)?;
408
409    let bar_type = BarType::new(
410        instrument.id(),
411        resolution.to_bar_spec(),
412        AggregationSource::External,
413    );
414
415    Bar::new_checked(bar_type, open, high, low, close, volume, ts_event, ts_init)
416        .map_err(|e| anyhow::anyhow!("invalid candle bar: {e}"))
417}
418
419fn parse_book_level_delta(
420    level: &LighterPriceLevel,
421    instrument: &InstrumentAny,
422    side: OrderSide,
423    sequence: u64,
424    ts_event: UnixNanos,
425    ts_init: UnixNanos,
426    flags: u8,
427) -> anyhow::Result<OrderBookDelta> {
428    let price = price_from_decimal(level.price, instrument.price_precision())?;
429    let size = quantity_from_decimal(level.size, instrument.size_precision())?;
430    let action = if flags & RecordFlag::F_SNAPSHOT as u8 != 0 {
431        BookAction::Add
432    } else if size.is_zero() {
433        BookAction::Delete
434    } else {
435        BookAction::Update
436    };
437    let order = BookOrder::new(side, price, size, 0);
438
439    OrderBookDelta::new_checked(
440        instrument.id(),
441        action,
442        order,
443        flags,
444        sequence,
445        ts_event,
446        ts_init,
447    )
448    .context("failed to construct Lighter WebSocket book delta")
449}
450
451fn book_flags(is_snapshot: bool, processed: usize, total_levels: usize) -> u8 {
452    let mut flags = if is_snapshot {
453        RecordFlag::F_SNAPSHOT as u8
454    } else {
455        0
456    };
457
458    if processed == total_levels {
459        flags |= RecordFlag::F_LAST as u8;
460    }
461
462    flags
463}
464
465/// Parses a Lighter account-stream order payload into an [`OrderStatusReport`].
466///
467/// The venue exposes partial fills implicitly: the order remains in `Open`
468/// status with `filled_base_amount > 0`. The mapping promotes such an order
469/// to [`OrderStatus::PartiallyFilled`] so downstream consumers do not need to
470/// re-derive it.
471///
472/// # Errors
473///
474/// Returns an error if any quantity, price, or timestamp field cannot be
475/// converted, or if the venue order kind has no Nautilus equivalent.
476pub fn parse_ws_order_status_report(
477    order: &LighterOrder,
478    instrument: &InstrumentAny,
479    account_id: AccountId,
480    ts_init: UnixNanos,
481) -> anyhow::Result<OrderStatusReport> {
482    let instrument_id = instrument.id();
483    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
484    let order_side = order
485        .side
486        .map_or_else(|| order_side_from_is_ask(order.is_ask), nautilus_order_side);
487    let mut order_type = nautilus_order_type(order.order_type)?;
488    if is_order_triggered(order)
489        && order.trigger_price.is_zero()
490        && matches!(order_type, OrderType::StopLimit | OrderType::LimitIfTouched)
491    {
492        order_type = OrderType::Limit;
493    }
494
495    let (time_in_force, expire_time) =
496        nautilus_time_in_force(order.time_in_force, order.order_expiry);
497    let post_only = order.time_in_force == LighterOrderTimeInForce::PostOnly;
498
499    let quantity = quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
500    let filled_qty = quantity_from_decimal(order.filled_base_amount, instrument.size_precision())?;
501    let mut order_status = nautilus_order_status(order.status, &filled_qty);
502    if order_status == OrderStatus::Accepted
503        && is_order_triggered(order)
504        && TRIGGERABLE_ORDER_TYPES.contains(&order_type)
505    {
506        order_status = OrderStatus::Triggered;
507    }
508
509    let cancel_reason = order.status.as_cancel_reason();
510
511    let ts_accepted = parse_optional_order_timestamp(order.created_at)?;
512    let ts_last = parse_optional_order_timestamp(order.updated_at)?;
513
514    let mut report = OrderStatusReport::new(
515        account_id,
516        instrument_id,
517        None, // client_order_id set below when present
518        venue_order_id,
519        order_side.into(),
520        order_type,
521        time_in_force,
522        order_status,
523        quantity,
524        filled_qty,
525        ts_accepted,
526        ts_last,
527        ts_init,
528        Some(UUID4::new()),
529    )
530    .with_post_only(post_only)
531    .with_reduce_only(order.reduce_only);
532
533    if !order.client_order_id.is_empty() && order.client_order_id != "0" {
534        report = report.with_client_order_id(ClientOrderId::new(order.client_order_id.as_str()));
535    }
536
537    if let Some(price) = parse_optional_price(order.price, instrument.price_precision())? {
538        report = report.with_price(price);
539    }
540
541    if let Some(trigger_price) =
542        parse_optional_price(order.trigger_price, instrument.price_precision())?
543    {
544        report = report.with_trigger_price(trigger_price);
545    }
546
547    if order_type_requires_trigger_type(order_type) {
548        report = report.with_trigger_type(TriggerType::Default);
549    }
550
551    if let Some(expire) = expire_time {
552        report = report.with_expire_time(expire);
553    }
554
555    if let Some(reason) = cancel_reason {
556        report = report.with_cancel_reason(reason.to_string());
557    }
558
559    // Lighter publishes `parent_order_id` in the venue namespace (matching
560    // `order_id`), not the client namespace. Nautilus
561    // `OrderStatusReport::parent_order_id` expects a `ClientOrderId`, so
562    // populating it from the venue id would mislabel namespaces. Contingency
563    // linking for OTO/OCO groups must be applied at the execution-client
564    // layer, where the venue-to-client id mapping is tracked.
565
566    Ok(report)
567}
568
569pub(crate) fn is_order_triggered(order: &LighterOrder) -> bool {
570    order.status == LighterOrderStatus::Open
571        && (order.trigger_status == LighterTriggerStatus::Ready
572            || (order.trigger_status == LighterTriggerStatus::Na
573                && matches!(
574                    order.order_type,
575                    LighterOrderKind::StopLoss
576                        | LighterOrderKind::StopLossLimit
577                        | LighterOrderKind::TakeProfit
578                        | LighterOrderKind::TakeProfitLimit
579                )))
580}
581
582fn order_type_requires_trigger_type(order_type: OrderType) -> bool {
583    matches!(
584        order_type,
585        OrderType::StopMarket
586            | OrderType::StopLimit
587            | OrderType::MarketIfTouched
588            | OrderType::LimitIfTouched
589            | OrderType::TrailingStopMarket
590            | OrderType::TrailingStopLimit
591    )
592}
593
594/// Parses a Lighter account-trade payload into a [`FillReport`] when the trade
595/// involves the supplied account.
596///
597/// Returns `Ok(None)` if `account_index` is neither the bid nor ask account on
598/// the trade. The handler routes account-stream trades through this parser, so
599/// crossed pairs the user is not part of (e.g. when sharing a market with
600/// other participants) are skipped silently rather than misattributed.
601///
602/// # Errors
603///
604/// Returns an error if:
605/// - The trade ID, price, size, or timestamp cannot be converted.
606/// - The commission calculation overflows or cannot be represented as [`Money`].
607pub fn parse_ws_fill_report(
608    trade: &LighterTrade,
609    account_index: i64,
610    instrument: &InstrumentAny,
611    account_id: AccountId,
612    ts_init: UnixNanos,
613) -> anyhow::Result<Option<FillReport>> {
614    let user_is_bidder = trade.bid_account_id == account_index;
615    let user_is_asker = trade.ask_account_id == account_index;
616    if !user_is_bidder && !user_is_asker {
617        return Ok(None);
618    }
619
620    let order_side = if user_is_bidder {
621        OrderSide::Buy
622    } else {
623        OrderSide::Sell
624    };
625
626    // `is_maker_ask` says which book side rested as the maker; combined with
627    // which side the user filled, that determines whether the user provided
628    // or removed liquidity.
629    let liquidity_side = if user_is_asker == trade.is_maker_ask {
630        LiquiditySide::Maker
631    } else {
632        LiquiditySide::Taker
633    };
634
635    let venue_order_id = if user_is_bidder {
636        venue_order_id_from(trade.bid_id_str.as_deref(), trade.bid_id)
637    } else {
638        venue_order_id_from(trade.ask_id_str.as_deref(), trade.ask_id)
639    };
640
641    let trade_id = parse_lighter_trade_id(trade)?;
642
643    let last_qty = quantity_from_decimal(trade.size, instrument.size_precision())?;
644    let last_px = price_from_decimal(trade.price, instrument.price_precision())?;
645
646    let fee_value = if liquidity_side == LiquiditySide::Maker {
647        trade.maker_fee
648    } else {
649        trade.taker_fee
650    };
651
652    let commission = lighter_fee_to_commission(fee_value, trade, instrument.quote_currency())?;
653    let client_order_id = if user_is_bidder {
654        client_order_id_from(trade.bid_client_id_str.as_deref(), trade.bid_client_id)
655    } else {
656        client_order_id_from(trade.ask_client_id_str.as_deref(), trade.ask_client_id)
657    };
658
659    let timestamp_ms =
660        u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
661    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
662
663    Ok(Some(FillReport::new(
664        account_id,
665        instrument.id(),
666        venue_order_id,
667        trade_id,
668        order_side,
669        last_qty,
670        last_px,
671        commission,
672        liquidity_side,
673        client_order_id,
674        None, // venue_position_id: Lighter perps run NETTING (one position per market)
675        ts_event,
676        ts_init,
677        Some(UUID4::new()),
678    )))
679}
680
681pub(crate) fn parse_lighter_trade_id(trade: &LighterTrade) -> anyhow::Result<TradeId> {
682    match trade.trade_id_str.as_deref() {
683        Some(s) => TradeId::new_checked(s),
684        None => TradeId::new_checked(trade.trade_id.to_string()),
685    }
686    .context("invalid Lighter trade identifier")
687}
688
689/// Outcome of [`parse_lighter_order_event`] for tracked orders.
690///
691/// Parses raw order events into typed intermediate events (see
692/// the execution
693/// consumption loop maps these into [`nautilus_model::events::OrderEventAny`]
694/// variants for tracked orders; untracked orders flow through the
695/// `OrderStatusReport` path instead.
696#[derive(Debug, Clone)]
697#[allow(clippy::large_enum_variant)]
698pub(crate) enum ParsedOrderEvent {
699    Accepted(OrderAccepted),
700    Canceled(OrderCanceled),
701    Expired(OrderExpired),
702    Triggered(OrderTriggered),
703    Rejected(OrderRejected),
704    Updated(OrderUpdated),
705    UpdatedThenTriggered {
706        updated: OrderUpdated,
707        triggered: OrderTriggered,
708    },
709}
710
711/// Inputs that the consumption-loop dispatcher hands to
712/// [`parse_lighter_order_event`] for an acknowledged `Pending` or `Open`
713/// frame. The dispatcher precomputes the accept/trigger gates and the
714/// modify-detection diff against
715/// [`crate::websocket::dispatch::WsDispatchState::order_snapshots`]. The parser
716/// uses the flags to pick the correct typed event without dispatch state
717/// lookups.
718#[derive(Debug, Clone, Copy)]
719pub(crate) struct OpenFrameContext {
720    /// `true` if an `OrderAccepted` has already been emitted for the cloid.
721    pub(crate) accepted_already_emitted: bool,
722    /// `true` if an `OrderTriggered` has already been emitted for the cloid.
723    pub(crate) triggered_already_emitted: bool,
724    /// `true` if (qty, price, trigger_price) differ from the last stored
725    /// snapshot. Computed by the caller; only meaningful when
726    /// `accepted_already_emitted` is `true`.
727    pub(crate) shape_changed: bool,
728}
729
730/// Extract the mutable shape (qty / price / trigger) from a Lighter order
731/// payload so the dispatcher can diff against the stored snapshot. Returns
732/// the values in the instrument's precision so equality is well-defined.
733///
734/// # Errors
735///
736/// Returns an error if any price or quantity field cannot be converted at
737/// the instrument's precision.
738pub(crate) fn lighter_order_shape(
739    order: &LighterOrder,
740    instrument: &InstrumentAny,
741    order_type: OrderType,
742) -> anyhow::Result<OrderShapeSnapshot> {
743    let quantity = quantity_from_decimal(order.initial_base_amount, instrument.size_precision())?;
744    let price = if LIMIT_ORDER_TYPES.contains(&order_type) {
745        parse_optional_price(order.price, instrument.price_precision())?
746    } else {
747        None
748    };
749    let trigger_price = if STOP_ORDER_TYPES.contains(&order_type) {
750        parse_optional_price(order.trigger_price, instrument.price_precision())?
751    } else {
752        None
753    };
754    Ok(OrderShapeSnapshot {
755        quantity,
756        price,
757        trigger_price,
758    })
759}
760
761/// Build a typed order event from a Lighter `account_orders` payload, using
762/// the identity context captured at submit time.
763///
764/// The caller (the execution consumption loop) decides between this path and
765/// the [`OrderStatusReport`] fallback based on whether the cloid is in
766/// `WsDispatchState::order_identities`. Returns `None` for `InProgress` and
767/// unchanged `Filled` frames. A changed `Filled` frame produces `Updated`;
768/// actual fills flow through the trade stream via [`parse_lighter_order_filled`].
769/// Lighter `Pending` means the venue has
770/// acknowledged the order, so it follows the accepted/update path without
771/// the `Open`-only trigger transition.
772///
773/// The `Open` branch decision matrix (in order):
774///
775/// - Already accepted with a fresh trigger and a changed shape
776///   -> `Updated`, then `Triggered`.
777/// - An open conditional limit with `Ready` or `Na` trigger status and no prior trigger -> `Triggered`.
778/// - Not yet accepted -> `Accepted` (the dispatcher seeds the shape
779///   snapshot so subsequent diffs are meaningful).
780/// - Already accepted and the order shape changed (qty / price / trigger)
781///   -> `Updated` (the dispatcher refreshes the shape snapshot).
782/// - Already accepted with no shape change -> `None` (snapshot replay).
783///
784/// # Errors
785///
786/// Returns an error if any price, quantity, or timestamp field cannot be
787/// converted.
788#[expect(
789    clippy::too_many_arguments,
790    reason = "identity and the precomputed open-frame context are independent inputs threaded by the caller"
791)]
792pub(crate) fn parse_lighter_order_event(
793    order: &LighterOrder,
794    instrument: &InstrumentAny,
795    identity: &OrderIdentity,
796    cloid: ClientOrderId,
797    account_id: AccountId,
798    trader_id: TraderId,
799    open_ctx: OpenFrameContext,
800    ts_init: UnixNanos,
801) -> anyhow::Result<Option<ParsedOrderEvent>> {
802    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
803    let ts_event = parse_optional_order_timestamp(order.updated_at)?;
804    let ts_accept = parse_optional_order_timestamp(order.created_at)?;
805
806    match order.status {
807        LighterOrderStatus::InProgress => Ok(None),
808        LighterOrderStatus::Pending | LighterOrderStatus::Open => {
809            let fresh_trigger = is_order_triggered(order)
810                && TRIGGERABLE_ORDER_TYPES.contains(&identity.order_type)
811                && !open_ctx.triggered_already_emitted;
812
813            if fresh_trigger && open_ctx.accepted_already_emitted && open_ctx.shape_changed {
814                let updated = parse_lighter_order_updated(
815                    order, instrument, identity, cloid, account_id, trader_id, ts_init,
816                )?;
817
818                let triggered = OrderTriggered::new(
819                    trader_id,
820                    identity.strategy_id,
821                    identity.instrument_id,
822                    cloid,
823                    UUID4::new(),
824                    ts_event,
825                    ts_init,
826                    false,
827                    Some(venue_order_id),
828                    Some(account_id),
829                );
830                Ok(Some(ParsedOrderEvent::UpdatedThenTriggered {
831                    updated,
832                    triggered,
833                }))
834            } else if fresh_trigger {
835                let triggered = OrderTriggered::new(
836                    trader_id,
837                    identity.strategy_id,
838                    identity.instrument_id,
839                    cloid,
840                    UUID4::new(),
841                    ts_event,
842                    ts_init,
843                    false,
844                    Some(venue_order_id),
845                    Some(account_id),
846                );
847                Ok(Some(ParsedOrderEvent::Triggered(triggered)))
848            } else if !open_ctx.accepted_already_emitted {
849                let accepted = OrderAccepted::new(
850                    trader_id,
851                    identity.strategy_id,
852                    identity.instrument_id,
853                    cloid,
854                    venue_order_id,
855                    account_id,
856                    UUID4::new(),
857                    ts_accept,
858                    ts_init,
859                    false,
860                );
861                Ok(Some(ParsedOrderEvent::Accepted(accepted)))
862            } else if open_ctx.shape_changed {
863                let updated = parse_lighter_order_updated(
864                    order, instrument, identity, cloid, account_id, trader_id, ts_init,
865                )?;
866                Ok(Some(ParsedOrderEvent::Updated(updated)))
867            } else {
868                Ok(None)
869            }
870        }
871        LighterOrderStatus::Filled => {
872            if open_ctx.accepted_already_emitted && open_ctx.shape_changed {
873                let updated = parse_lighter_order_updated(
874                    order, instrument, identity, cloid, account_id, trader_id, ts_init,
875                )?;
876                Ok(Some(ParsedOrderEvent::Updated(updated)))
877            } else {
878                Ok(None)
879            }
880        }
881        LighterOrderStatus::CanceledExpired => {
882            let expired = OrderExpired::new(
883                trader_id,
884                identity.strategy_id,
885                identity.instrument_id,
886                cloid,
887                UUID4::new(),
888                ts_event,
889                ts_init,
890                false,
891                Some(venue_order_id),
892                Some(account_id),
893            );
894            Ok(Some(ParsedOrderEvent::Expired(expired)))
895        }
896        LighterOrderStatus::CanceledPostOnly => {
897            let rejected = OrderRejected::new(
898                trader_id,
899                identity.strategy_id,
900                identity.instrument_id,
901                cloid,
902                account_id,
903                Ustr::from("post-only"),
904                UUID4::new(),
905                ts_event,
906                ts_init,
907                false,
908                true, // due_post_only
909            );
910            Ok(Some(ParsedOrderEvent::Rejected(rejected)))
911        }
912        LighterOrderStatus::Canceled
913        | LighterOrderStatus::CanceledReduceOnly
914        | LighterOrderStatus::CanceledPositionNotAllowed
915        | LighterOrderStatus::CanceledMarginNotAllowed
916        | LighterOrderStatus::CanceledTooMuchSlippage
917        | LighterOrderStatus::CanceledNotEnoughLiquidity
918        | LighterOrderStatus::CanceledSelfTrade
919        | LighterOrderStatus::CanceledOco
920        | LighterOrderStatus::CanceledChild
921        | LighterOrderStatus::CanceledLiquidation
922        | LighterOrderStatus::CanceledInvalidBalance => {
923            let canceled = OrderCanceled::new(
924                trader_id,
925                identity.strategy_id,
926                identity.instrument_id,
927                cloid,
928                UUID4::new(),
929                ts_event,
930                ts_init,
931                false,
932                Some(venue_order_id),
933                Some(account_id),
934                order.status.as_cancel_reason().map(Ustr::from),
935            );
936            Ok(Some(ParsedOrderEvent::Canceled(canceled)))
937        }
938    }
939}
940
941pub(crate) fn parse_lighter_order_updated(
942    order: &LighterOrder,
943    instrument: &InstrumentAny,
944    identity: &OrderIdentity,
945    cloid: ClientOrderId,
946    account_id: AccountId,
947    trader_id: TraderId,
948    ts_init: UnixNanos,
949) -> anyhow::Result<OrderUpdated> {
950    let shape =
951        identity.normalize_shape(lighter_order_shape(order, instrument, identity.order_type)?);
952    let ts_event = parse_optional_order_timestamp(order.updated_at)?;
953    let venue_order_id = VenueOrderId::new(order.order_id.as_str());
954    Ok(OrderUpdated::new(
955        trader_id,
956        identity.strategy_id,
957        identity.instrument_id,
958        cloid,
959        shape.quantity,
960        UUID4::new(),
961        ts_event,
962        ts_init,
963        false,
964        Some(venue_order_id),
965        Some(account_id),
966        shape.price,
967        shape.trigger_price,
968        None,
969        false,
970    ))
971}
972
973/// Build an [`OrderFilled`] event from a Lighter `account_trades` payload,
974/// using the identity context captured at submit time.
975///
976/// Returns `Ok(None)` if the trade does not involve `account_index` (the
977/// venue forwards crossed pairs the account is not part of on the same
978/// channel). The caller dedupes by `TradeId` against
979/// `WsDispatchState::seen_trade_ids` to drop repeats on reconnect.
980///
981/// # Errors
982///
983/// Returns an error if:
984/// - The trade ID, price, size, or timestamp cannot be converted.
985/// - The commission calculation overflows or cannot be represented as [`Money`].
986#[expect(
987    clippy::too_many_arguments,
988    reason = "identity and account context are independent inputs threaded by the dispatcher"
989)]
990pub(crate) fn parse_lighter_order_filled(
991    trade: &LighterTrade,
992    instrument: &InstrumentAny,
993    identity: &OrderIdentity,
994    cloid: ClientOrderId,
995    account_id: AccountId,
996    trader_id: TraderId,
997    account_index: i64,
998    ts_init: UnixNanos,
999) -> anyhow::Result<Option<OrderFilled>> {
1000    let user_is_bidder = trade.bid_account_id == account_index;
1001    let user_is_asker = trade.ask_account_id == account_index;
1002    if !user_is_bidder && !user_is_asker {
1003        return Ok(None);
1004    }
1005
1006    let liquidity_side = if user_is_asker == trade.is_maker_ask {
1007        LiquiditySide::Maker
1008    } else {
1009        LiquiditySide::Taker
1010    };
1011
1012    let venue_order_id = if user_is_bidder {
1013        venue_order_id_from(trade.bid_id_str.as_deref(), trade.bid_id)
1014    } else {
1015        venue_order_id_from(trade.ask_id_str.as_deref(), trade.ask_id)
1016    };
1017
1018    let trade_id = match trade.trade_id_str.as_deref() {
1019        Some(s) => TradeId::new_checked(s),
1020        None => TradeId::new_checked(trade.trade_id.to_string()),
1021    }
1022    .context("invalid Lighter trade identifier")?;
1023
1024    let last_qty = quantity_from_decimal(trade.size, instrument.size_precision())?;
1025    let last_px = price_from_decimal(trade.price, instrument.price_precision())?;
1026
1027    let fee_value = if liquidity_side == LiquiditySide::Maker {
1028        trade.maker_fee
1029    } else {
1030        trade.taker_fee
1031    };
1032
1033    let commission = lighter_fee_to_commission(fee_value, trade, instrument.quote_currency())?;
1034
1035    let timestamp_ms =
1036        u64::try_from(trade.timestamp).context("negative Lighter trade timestamp")?;
1037    let ts_event = parse_millis_to_nanos(timestamp_ms)?;
1038
1039    Ok(Some(OrderFilled::new(
1040        trader_id,
1041        identity.strategy_id,
1042        identity.instrument_id,
1043        cloid,
1044        venue_order_id,
1045        account_id,
1046        trade_id,
1047        identity.order_side,
1048        identity.order_type,
1049        last_qty,
1050        last_px,
1051        instrument.quote_currency(),
1052        liquidity_side,
1053        UUID4::new(),
1054        ts_event,
1055        ts_init,
1056        false, // reconciliation
1057        None,  // venue_position_id: Lighter perps run NETTING
1058        Some(commission),
1059        None,
1060    )))
1061}
1062
1063/// Parses a Lighter position payload into a [`PositionStatusReport`].
1064///
1065/// The `account_all_positions` frame carries no top-level event timestamp;
1066/// callers should pass the wall-clock arrival time captured by the handler.
1067///
1068/// # Errors
1069///
1070/// Returns an error if the size or entry price cannot be converted.
1071pub fn parse_ws_position_status_report(
1072    position: &LighterPosition,
1073    instrument: &InstrumentAny,
1074    account_id: AccountId,
1075    ts_event: UnixNanos,
1076    ts_init: UnixNanos,
1077) -> anyhow::Result<PositionStatusReport> {
1078    let quantity = quantity_from_decimal(position.position, instrument.size_precision())?;
1079    let position_side = if quantity.is_zero() {
1080        PositionSide::Flat
1081    } else if position.sign < 0 {
1082        PositionSide::Short
1083    } else {
1084        PositionSide::Long
1085    };
1086
1087    let avg_px_open = if position_side == PositionSide::Flat {
1088        None
1089    } else {
1090        Some(position.avg_entry_price)
1091    };
1092
1093    Ok(PositionStatusReport::new(
1094        account_id,
1095        instrument.id(),
1096        position_side,
1097        quantity,
1098        ts_event,
1099        ts_init,
1100        Some(UUID4::new()),
1101        None, // venue_position_id: NETTING, Nautilus identifies by instrument
1102        avg_px_open,
1103    ))
1104}
1105
1106/// Builds the per-asset [`AccountBalance`] for one Lighter wallet entry.
1107///
1108/// Lighter is a unified-margin venue: spot-side `balance` and perp-side
1109/// `margin_balance` are both deployable equity. The split shows where the
1110/// money currently sits, not whether it's usable. The only true lock on
1111/// the trading side is `locked_balance` (resting spot orders); perp
1112/// margin currently in use is tracked separately via [`MarginBalance`],
1113/// not via `AccountBalance.locked`.
1114///
1115/// We map:
1116/// - `total  = balance + margin_balance`: every unit of that currency
1117///   the account owns at the venue
1118/// - `locked = locked_balance`: only spot-order reservations
1119/// - `free   = total - locked` (derived by `from_total_and_locked`):
1120///   all deployable equity, whether currently sitting on spot or perp
1121///
1122/// Perp-side margin currently allocated to positions is **not** folded
1123/// into `locked`. It lives on [`MarginBalance`] and the portfolio's
1124/// risk engine consumes it from there. See
1125/// [`margin_balance_from_user_stats`].
1126///
1127/// # Errors
1128///
1129/// Returns an error if `AccountBalance::from_total_and_locked` rejects
1130/// the computed values.
1131pub fn account_balance_from_lighter_asset(asset: &LighterAsset) -> anyhow::Result<AccountBalance> {
1132    let currency = Currency::get_or_create_crypto(asset.symbol);
1133    let total = asset.balance + asset.margin_balance;
1134    let locked = asset.locked_balance;
1135    AccountBalance::from_total_and_locked(total, locked, currency)
1136        .context("failed to construct Lighter account balance")
1137}
1138
1139/// Builds the cross-margin [`MarginBalance`] from a `user_stats` frame.
1140///
1141/// This compatibility entry point uses USDC. Deployment-aware clients call
1142/// [`margin_balance_from_user_stats_with_currency`].
1143///
1144/// `user_stats` is the deployment's perp-side settlement-currency rollup. We derive:
1145/// - `initial = max(collateral - available_balance, 0)`: collateral
1146///   currently allocated to open positions/orders
1147/// - `maintenance = 0`: Lighter does not publish maintenance margin on
1148///   `user_stats`. `margin_usage` looks like a maintenance ratio but is
1149///   actually the initial-margin-usage percentage
1150///   (`(collateral - available) / collateral * 100`), so it carries no
1151///   information about maintenance. Computing maintenance per-position
1152///   from the positions stream would be more accurate; until that's
1153///   wired we report zero rather than fabricate a value.
1154///
1155/// `instrument_id = None` routes this into `MarginAccount.account_margins`
1156/// (cross margin keyed by currency), which is what the portfolio reads for
1157/// venues running unified cross-margin mode.
1158///
1159/// # Errors
1160///
1161/// Returns an error if either `Money::from_decimal` call rejects the value.
1162pub fn margin_balance_from_user_stats(stats: &LighterUserStats) -> anyhow::Result<MarginBalance> {
1163    margin_balance_from_user_stats_with_currency(stats, Currency::get_or_create_crypto("USDC"))
1164}
1165
1166/// Builds the cross-margin [`MarginBalance`] in the supplied settlement currency.
1167///
1168/// # Errors
1169///
1170/// Returns an error if either `Money::from_decimal` call rejects the value.
1171pub fn margin_balance_from_user_stats_with_currency(
1172    stats: &LighterUserStats,
1173    settlement_currency: Currency,
1174) -> anyhow::Result<MarginBalance> {
1175    let initial_dec = (stats.collateral - stats.available_balance).max(Decimal::ZERO);
1176    let initial = Money::from_decimal(initial_dec, settlement_currency)
1177        .map_err(|e| anyhow::anyhow!("failed to construct initial margin: {e}"))?;
1178    let maintenance = Money::from_decimal(Decimal::ZERO, settlement_currency)
1179        .map_err(|e| anyhow::anyhow!("failed to construct maintenance margin: {e}"))?;
1180    Ok(MarginBalance::new(initial, maintenance, None))
1181}
1182
1183/// Assembles the unified [`AccountState`] from already-parsed components.
1184///
1185/// The reconciler in the `websocket::account_state` module owns the latest
1186/// snapshot of each input stream and calls this once per emission.
1187/// `AccountType::Margin` is invariant for Lighter; `base_currency` is `None`
1188/// because the account holds multiple spot currencies.
1189#[must_use]
1190pub fn build_unified_account_state(
1191    balances: Vec<AccountBalance>,
1192    margin: Option<MarginBalance>,
1193    account_id: AccountId,
1194    ts_event: UnixNanos,
1195    ts_init: UnixNanos,
1196) -> AccountState {
1197    let margins = margin.map(|m| vec![m]).unwrap_or_default();
1198    AccountState::new(
1199        account_id,
1200        AccountType::Margin,
1201        balances,
1202        margins,
1203        true,
1204        UUID4::new(),
1205        ts_event,
1206        ts_init,
1207        None,
1208    )
1209}
1210
1211/// Largest ten-digit Unix timestamp. Lighter order frames deliver `created_at` and
1212/// `updated_at` in seconds or milliseconds depending on the frame source, so parsing
1213/// normalizes second-scale values before nanosecond conversion.
1214const UNIX_TIMESTAMP_SECONDS_MAX: i64 = 9_999_999_999;
1215
1216fn parse_optional_order_timestamp(timestamp: i64) -> anyhow::Result<UnixNanos> {
1217    if timestamp <= 0 {
1218        return Ok(UnixNanos::default());
1219    }
1220
1221    let millis = if timestamp <= UNIX_TIMESTAMP_SECONDS_MAX {
1222        timestamp * 1_000
1223    } else {
1224        timestamp
1225    };
1226
1227    parse_millis_to_nanos(millis as u64)
1228}
1229
1230fn parse_optional_price(value: Decimal, precision: u8) -> anyhow::Result<Option<Price>> {
1231    if value.is_zero() {
1232        return Ok(None);
1233    }
1234    Price::from_decimal_dp(value, precision)
1235        .map(Some)
1236        .map_err(|e| anyhow::anyhow!("invalid price `{value}` at precision {precision}: {e}"))
1237}
1238
1239fn lighter_fee_to_commission(
1240    fee_ticks: Option<i32>,
1241    trade: &LighterTrade,
1242    currency: Currency,
1243) -> Result<Money, LighterCommissionError> {
1244    let ticks = fee_ticks.unwrap_or(0);
1245    let rate = Decimal::new(i64::from(ticks), FEE_DECIMALS);
1246    let amount = rate
1247        .checked_mul(trade.size)
1248        .and_then(|amount| amount.checked_mul(trade.price))
1249        .ok_or_else(|| LighterCommissionError::new("overflow calculating fee amount"))?;
1250    Money::from_decimal(amount, currency).map_err(|e| LighterCommissionError::new(e.to_string()))
1251}
1252
1253fn nautilus_order_side(side: LighterOrderSide) -> OrderSide {
1254    match side {
1255        LighterOrderSide::Buy => OrderSide::Buy,
1256        LighterOrderSide::Sell => OrderSide::Sell,
1257    }
1258}
1259
1260fn nautilus_order_type(kind: LighterOrderKind) -> anyhow::Result<OrderType> {
1261    match kind {
1262        LighterOrderKind::Limit => Ok(OrderType::Limit),
1263        LighterOrderKind::Market => Ok(OrderType::Market),
1264        LighterOrderKind::StopLoss => Ok(OrderType::StopMarket),
1265        LighterOrderKind::StopLossLimit => Ok(OrderType::StopLimit),
1266        LighterOrderKind::TakeProfit => Ok(OrderType::MarketIfTouched),
1267        LighterOrderKind::TakeProfitLimit => Ok(OrderType::LimitIfTouched),
1268        LighterOrderKind::Twap | LighterOrderKind::TwapSub | LighterOrderKind::Liquidation => Err(
1269            anyhow::anyhow!("Lighter `{kind:?}` has no Nautilus order-type equivalent",),
1270        ),
1271    }
1272}
1273
1274fn nautilus_time_in_force(
1275    tif: LighterOrderTimeInForce,
1276    order_expiry: i64,
1277) -> (TimeInForce, Option<UnixNanos>) {
1278    match tif {
1279        LighterOrderTimeInForce::ImmediateOrCancel => (TimeInForce::Ioc, None),
1280        LighterOrderTimeInForce::PostOnly | LighterOrderTimeInForce::GoodTillTime => {
1281            // Lighter uses positive expiry for GTD and nonpositive expiry for GTC;
1282            // PostOnly uses the same expiry field plus an independent report flag.
1283            if order_expiry > 0 {
1284                match parse_millis_to_nanos(order_expiry as u64) {
1285                    Ok(expiry) => (TimeInForce::Gtd, Some(expiry)),
1286                    Err(_) => (TimeInForce::Gtc, None),
1287                }
1288            } else {
1289                (TimeInForce::Gtc, None)
1290            }
1291        }
1292        LighterOrderTimeInForce::Unknown => (TimeInForce::Gtc, None),
1293    }
1294}
1295
1296fn nautilus_order_status(status: LighterOrderStatus, filled_qty: &Quantity) -> OrderStatus {
1297    match status {
1298        LighterOrderStatus::InProgress => OrderStatus::Submitted,
1299        LighterOrderStatus::Pending | LighterOrderStatus::Open => {
1300            if filled_qty.is_zero() {
1301                OrderStatus::Accepted
1302            } else {
1303                OrderStatus::PartiallyFilled
1304            }
1305        }
1306        LighterOrderStatus::Filled => OrderStatus::Filled,
1307        LighterOrderStatus::CanceledExpired => OrderStatus::Expired,
1308        LighterOrderStatus::CanceledPostOnly => OrderStatus::Rejected,
1309        LighterOrderStatus::Canceled
1310        | LighterOrderStatus::CanceledReduceOnly
1311        | LighterOrderStatus::CanceledPositionNotAllowed
1312        | LighterOrderStatus::CanceledMarginNotAllowed
1313        | LighterOrderStatus::CanceledTooMuchSlippage
1314        | LighterOrderStatus::CanceledNotEnoughLiquidity
1315        | LighterOrderStatus::CanceledSelfTrade
1316        | LighterOrderStatus::CanceledOco
1317        | LighterOrderStatus::CanceledChild
1318        | LighterOrderStatus::CanceledLiquidation
1319        | LighterOrderStatus::CanceledInvalidBalance => OrderStatus::Canceled,
1320    }
1321}
1322
1323/// Resolve a venue order id from the venue-provided string field, falling
1324/// back to the numeric `i64` mirror when the string field is absent. Skips
1325/// the `Option<String>::clone` the previous code paid on every fill.
1326fn venue_order_id_from(str_field: Option<&str>, numeric_fallback: i64) -> VenueOrderId {
1327    match str_field {
1328        Some(s) => VenueOrderId::new(s),
1329        None => VenueOrderId::new(numeric_fallback.to_string()),
1330    }
1331}
1332
1333/// Resolve an optional client order id from the venue's string field, treating
1334/// empty and the sentinel `"0"` as absent; falls back to the numeric `i64`
1335/// mirror when the string field is `None`.
1336fn client_order_id_from(str_field: Option<&str>, numeric_fallback: i64) -> Option<ClientOrderId> {
1337    match str_field {
1338        Some(s) if !s.is_empty() && s != "0" => Some(ClientOrderId::new(s)),
1339        None if numeric_fallback != 0 => Some(ClientOrderId::new(numeric_fallback.to_string())),
1340        _ => None,
1341    }
1342}
1343
1344#[cfg(test)]
1345mod tests {
1346    use std::str::FromStr;
1347
1348    use nautilus_model::{
1349        enums::{BarAggregation, PriceType},
1350        identifiers::{InstrumentId, StrategyId, Symbol, Venue},
1351        instruments::CryptoPerpetual,
1352        types::{Price, Quantity, currency::Currency},
1353    };
1354    use rstest::rstest;
1355    use ustr::Ustr;
1356
1357    use super::*;
1358    use crate::{
1359        common::enums::LighterTradeType,
1360        http::models::LighterTrade,
1361        websocket::messages::{LighterMarketStats, LighterSpotMarketStats},
1362    };
1363
1364    fn create_test_instrument() -> InstrumentAny {
1365        create_test_instrument_with_quote(Venue::new("LIGHTER"), Currency::from("USDC"))
1366    }
1367
1368    fn create_test_instrument_with_quote(venue: Venue, quote_currency: Currency) -> InstrumentAny {
1369        let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), venue);
1370
1371        InstrumentAny::CryptoPerpetual(
1372            CryptoPerpetual::builder()
1373                .instrument_id(instrument_id)
1374                .raw_symbol(Symbol::new("ETH-PERP"))
1375                .base_currency(Currency::from("ETH"))
1376                .quote_currency(quote_currency)
1377                .settlement_currency(quote_currency)
1378                .is_inverse(false)
1379                .price_precision(2)
1380                .size_precision(4)
1381                .price_increment(Price::from("0.01"))
1382                .size_increment(Quantity::from("0.0001"))
1383                .ts_event(UnixNanos::default())
1384                .ts_init(UnixNanos::default())
1385                .build()
1386                .unwrap(),
1387        )
1388    }
1389
1390    fn stub_book() -> LighterWsOrderBook {
1391        LighterWsOrderBook {
1392            code: 0,
1393            asks: vec![LighterPriceLevel {
1394                price: Decimal::from_str("2064.54").unwrap(),
1395                size: Decimal::from_str("0.3285").unwrap(),
1396            }],
1397            bids: vec![LighterPriceLevel {
1398                price: Decimal::from_str("2064.30").unwrap(),
1399                size: Decimal::from_str("1.0392").unwrap(),
1400            }],
1401            offset: 1558300,
1402            nonce: 9182390020,
1403            last_updated_at: 1774884082309144,
1404            begin_nonce: 9182389998,
1405        }
1406    }
1407
1408    fn stub_market_stats() -> LighterMarketStats {
1409        LighterMarketStats {
1410            symbol: Ustr::from("ETH"),
1411            market_id: 0,
1412            index_price: Decimal::from_str("2064.48").unwrap(),
1413            mark_price: Decimal::from_str("2064.47").unwrap(),
1414            mid_price: Decimal::from_str("2064.39").unwrap(),
1415            open_interest: Decimal::from_str("27250.8411").unwrap(),
1416            open_interest_limit: Decimal::from_str("50000.0000").unwrap(),
1417            funding_clamp_small: Decimal::from_str("0.0001").unwrap(),
1418            funding_clamp_big: Decimal::from_str("0.0002").unwrap(),
1419            last_trade_price: Decimal::from_str("2064.50").unwrap(),
1420            current_funding_rate: Decimal::from_str("0.000001").unwrap(),
1421            funding_rate: Decimal::from_str("0.000002").unwrap(),
1422            funding_timestamp: 1_774_879_200_000,
1423            daily_base_token_volume: Decimal::new(1_999_586_931, 4),
1424            daily_quote_token_volume: Decimal::new(471_193_598_847_246, 6),
1425            daily_price_low: Decimal::new(231_181, 2),
1426            daily_price_high: Decimal::new(2_398, 0),
1427            daily_price_change: Decimal::new(16_854_147_780_232_130, 17),
1428        }
1429    }
1430
1431    fn stub_spot_market_stats() -> LighterSpotMarketStats {
1432        LighterSpotMarketStats {
1433            symbol: Ustr::from("ETH"),
1434            market_id: 2048,
1435            index_price: Decimal::from_str("1.000000").unwrap(),
1436            mid_price: Decimal::from_str("1.000001").unwrap(),
1437            last_trade_price: Decimal::from_str("1.000002").unwrap(),
1438            daily_base_token_volume: Decimal::from(1000),
1439            daily_quote_token_volume: Decimal::new(10_001, 1),
1440            daily_price_low: Decimal::new(999_999, 6),
1441            daily_price_high: Decimal::new(1_000_002, 6),
1442            daily_price_change: Decimal::new(1, 6),
1443        }
1444    }
1445
1446    #[rstest]
1447    fn test_parse_ws_order_book_deltas_snapshot() {
1448        let instrument = create_test_instrument();
1449        let ts_init = UnixNanos::from(1);
1450
1451        let deltas =
1452            parse_ws_order_book_deltas(&stub_book(), &instrument, 1774884082326, true, ts_init)
1453                .unwrap();
1454
1455        assert_eq!(deltas.deltas.len(), 3);
1456        assert_eq!(deltas.deltas[0].action, BookAction::Clear);
1457        assert_eq!(deltas.deltas[1].action, BookAction::Add);
1458        assert_eq!(deltas.deltas[1].order.side, OrderSide::Buy.into());
1459        assert_eq!(deltas.deltas[1].order.price, Price::from("2064.30"));
1460        assert_eq!(deltas.deltas[1].order.size, Quantity::from("1.0392"));
1461        assert_eq!(deltas.deltas[2].order.side, OrderSide::Sell.into());
1462        assert_eq!(deltas.deltas[2].order.price, Price::from("2064.54"));
1463        assert_eq!(deltas.deltas[2].order.size, Quantity::from("0.3285"));
1464        assert_eq!(deltas.deltas[0].sequence, 9_182_390_020);
1465        assert_eq!(deltas.deltas[1].sequence, 9_182_390_020);
1466        assert_eq!(deltas.deltas[2].sequence, 9_182_390_020);
1467        assert_eq!(deltas.sequence, 9_182_390_020);
1468        assert_eq!(
1469            deltas.deltas[2].flags & RecordFlag::F_LAST as u8,
1470            RecordFlag::F_LAST as u8,
1471        );
1472    }
1473
1474    #[rstest]
1475    fn test_parse_ws_order_book_deltas_update_delete_zero_size() {
1476        let instrument = create_test_instrument();
1477        let mut book = stub_book();
1478        book.asks[0].size = Decimal::ZERO;
1479
1480        let deltas = parse_ws_order_book_deltas(
1481            &book,
1482            &instrument,
1483            1774884082326,
1484            false,
1485            UnixNanos::from(1),
1486        )
1487        .unwrap();
1488
1489        assert_eq!(deltas.deltas.len(), 2);
1490        assert_eq!(deltas.deltas[0].action, BookAction::Update);
1491        assert_eq!(deltas.deltas[0].order.side, OrderSide::Buy.into());
1492        assert_eq!(deltas.deltas[0].order.price, Price::from("2064.30"));
1493        assert_eq!(deltas.deltas[1].action, BookAction::Delete);
1494        assert_eq!(deltas.deltas[1].order.side, OrderSide::Sell.into());
1495        assert_eq!(deltas.deltas[1].order.price, Price::from("2064.54"));
1496    }
1497
1498    #[rstest]
1499    fn test_parse_ws_order_book_deltas_rejects_negative_nonce() {
1500        let instrument = create_test_instrument();
1501        let mut book = stub_book();
1502        book.nonce = -1;
1503
1504        let err = parse_ws_order_book_deltas(
1505            &book,
1506            &instrument,
1507            1774884082326,
1508            false,
1509            UnixNanos::from(1),
1510        )
1511        .unwrap_err();
1512
1513        assert!(err.to_string().contains("negative Lighter book nonce"));
1514    }
1515
1516    #[rstest]
1517    fn test_parse_ws_order_book_deltas_rejects_empty_update() {
1518        let instrument = create_test_instrument();
1519        let mut book = stub_book();
1520        book.asks.clear();
1521        book.bids.clear();
1522
1523        let err = parse_ws_order_book_deltas(
1524            &book,
1525            &instrument,
1526            1774884082326,
1527            false,
1528            UnixNanos::from(1),
1529        )
1530        .unwrap_err();
1531
1532        assert!(
1533            err.to_string()
1534                .contains("empty Lighter WebSocket order book update")
1535        );
1536    }
1537
1538    #[rstest]
1539    fn test_parse_ws_order_book_deltas_rejects_zero_size_snapshot_level() {
1540        let instrument = create_test_instrument();
1541        let mut book = stub_book();
1542        book.bids[0].size = Decimal::ZERO;
1543
1544        let err =
1545            parse_ws_order_book_deltas(&book, &instrument, 1774884082326, true, UnixNanos::from(1))
1546                .unwrap_err();
1547
1548        assert!(
1549            err.to_string()
1550                .contains("failed to construct Lighter WebSocket book delta")
1551        );
1552    }
1553
1554    #[rstest]
1555    fn test_parse_ws_quote_tick() {
1556        let instrument = create_test_instrument();
1557        let ticker = LighterTicker {
1558            s: Ustr::from("ETH"),
1559            a: LighterPriceLevel {
1560                price: Decimal::from_str("2064.48").unwrap(),
1561                size: Decimal::from_str("0.4950").unwrap(),
1562            },
1563            b: LighterPriceLevel {
1564                price: Decimal::from_str("2064.30").unwrap(),
1565                size: Decimal::from_str("1.0392").unwrap(),
1566            },
1567            last_updated_at: 1774883844921166,
1568        };
1569
1570        let quote = parse_ws_quote_tick(&ticker, &instrument, 1774883844933, UnixNanos::from(1))
1571            .unwrap()
1572            .expect("two-sided ticker yields a quote");
1573
1574        assert_eq!(quote.instrument_id, instrument.id());
1575        assert_eq!(quote.bid_price, Price::from("2064.30"));
1576        assert_eq!(quote.ask_price, Price::from("2064.48"));
1577        assert_eq!(quote.bid_size, Quantity::from("1.0392"));
1578        assert_eq!(quote.ask_size, Quantity::from("0.4950"));
1579        assert_eq!(quote.ts_event, UnixNanos::from(1_774_883_844_933_000_000),);
1580    }
1581
1582    #[rstest]
1583    fn test_parse_ws_quote_tick_skips_one_sided_book() {
1584        let instrument = create_test_instrument();
1585        let ticker = LighterTicker {
1586            s: Ustr::from("ETH"),
1587            a: LighterPriceLevel {
1588                price: Decimal::from_str("2064.48").unwrap(),
1589                size: Decimal::from_str("0.4950").unwrap(),
1590            },
1591            b: LighterPriceLevel {
1592                // Lighter emits empty strings when one side has no resting
1593                // orders; the wire deserializer maps those to `Decimal::ZERO`.
1594                price: Decimal::ZERO,
1595                size: Decimal::ZERO,
1596            },
1597            last_updated_at: 1774883844921166,
1598        };
1599
1600        let result =
1601            parse_ws_quote_tick(&ticker, &instrument, 1774883844933, UnixNanos::from(1)).unwrap();
1602
1603        assert!(result.is_none());
1604    }
1605
1606    #[rstest]
1607    fn test_parse_ws_quote_tick_rejects_invalid_price() {
1608        // With Decimal model fields, wire-malformed prices are rejected at
1609        // JSON deserialize time; this test guards the deserialize boundary so
1610        // a bad payload does not silently construct a zero-priced quote.
1611        let payload = serde_json::json!({
1612            "s": "ETH",
1613            "a": {"price": "not-a-price", "size": "0.4950"},
1614            "b": {"price": "2064.30", "size": "1.0392"},
1615            "last_updated_at": 1774883844921166u64,
1616        });
1617
1618        let err = serde_json::from_value::<LighterTicker>(payload).unwrap_err();
1619        assert!(err.to_string().to_lowercase().contains("decimal"));
1620    }
1621
1622    #[rstest]
1623    fn test_parse_ws_mark_price_update() {
1624        let instrument = create_test_instrument();
1625
1626        let update = parse_ws_mark_price_update(
1627            &stub_market_stats(),
1628            &instrument,
1629            1_774_883_844_933,
1630            UnixNanos::from(1),
1631        )
1632        .unwrap();
1633
1634        assert_eq!(update.instrument_id, instrument.id());
1635        assert_eq!(update.value, Price::from("2064.47"));
1636        assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1637        assert_eq!(update.ts_init, UnixNanos::from(1));
1638    }
1639
1640    #[rstest]
1641    fn test_parse_ws_index_price_update() {
1642        let instrument = create_test_instrument();
1643
1644        let update = parse_ws_index_price_update(
1645            &stub_market_stats(),
1646            &instrument,
1647            1_774_883_844_933,
1648            UnixNanos::from(1),
1649        )
1650        .unwrap();
1651
1652        assert_eq!(update.instrument_id, instrument.id());
1653        assert_eq!(update.value, Price::from("2064.48"));
1654        assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1655    }
1656
1657    #[rstest]
1658    fn test_parse_ws_spot_index_price_update() {
1659        let instrument = create_test_instrument();
1660
1661        let update = parse_ws_spot_index_price_update(
1662            &stub_spot_market_stats(),
1663            &instrument,
1664            1_774_883_844_933,
1665            UnixNanos::from(1),
1666        )
1667        .unwrap();
1668
1669        assert_eq!(update.instrument_id, instrument.id());
1670        assert_eq!(update.value, Price::from("1.00"));
1671        assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1672    }
1673
1674    #[rstest]
1675    fn test_parse_ws_funding_rate_update_omits_last_payment_timestamp() {
1676        let instrument = create_test_instrument();
1677        let stats = stub_market_stats();
1678        let ts_init = UnixNanos::from(1_774_883_900_000_000_000);
1679
1680        let update =
1681            parse_ws_funding_rate_update(&stats, &instrument, 1_774_883_844_933, ts_init).unwrap();
1682
1683        assert_eq!(stats.funding_timestamp, 1_774_879_200_000);
1684        assert_eq!(update.instrument_id, instrument.id());
1685        assert_eq!(update.rate, Decimal::from_str("0.000001").unwrap());
1686        assert_eq!(update.interval, None);
1687        assert_eq!(update.next_funding_ns, None);
1688        assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
1689        assert_eq!(update.ts_init, ts_init);
1690    }
1691
1692    // Pins which field each price-update parser reads from `LighterMarketStats`.
1693    // The three parsers share `build_price_update`; without these guards a
1694    // regression that swaps `mark_price` and `index_price` (or that has
1695    // `funding_rate_update` accidentally reading `funding_rate` instead of
1696    // `current_funding_rate`) would only fail one of the three happy-path
1697    // tests above and might pass them all if their fixtures happen to align.
1698    #[rstest]
1699    fn test_parse_ws_mark_price_update_reads_mark_field_only() {
1700        let instrument = create_test_instrument();
1701        let mut stats = stub_market_stats();
1702        let sentinel = Decimal::from_str("1234.56").unwrap();
1703        let other = Decimal::from_str("9999.99").unwrap();
1704        stats.mark_price = sentinel;
1705        stats.index_price = other;
1706        stats.mid_price = other;
1707        stats.last_trade_price = other;
1708
1709        let update =
1710            parse_ws_mark_price_update(&stats, &instrument, 1_774_883_844_933, UnixNanos::from(1))
1711                .unwrap();
1712
1713        assert_eq!(update.value, Price::from("1234.56"));
1714    }
1715
1716    #[rstest]
1717    fn test_parse_ws_index_price_update_reads_index_field_only() {
1718        let instrument = create_test_instrument();
1719        let mut stats = stub_market_stats();
1720        let sentinel = Decimal::from_str("1234.56").unwrap();
1721        let other = Decimal::from_str("9999.99").unwrap();
1722        stats.index_price = sentinel;
1723        stats.mark_price = other;
1724        stats.mid_price = other;
1725        stats.last_trade_price = other;
1726
1727        let update =
1728            parse_ws_index_price_update(&stats, &instrument, 1_774_883_844_933, UnixNanos::from(1))
1729                .unwrap();
1730
1731        assert_eq!(update.value, Price::from("1234.56"));
1732    }
1733
1734    #[rstest]
1735    fn test_parse_ws_funding_rate_update_reads_current_funding_rate_field_only() {
1736        let instrument = create_test_instrument();
1737        let mut stats = stub_market_stats();
1738        // `funding_rate` is the prior payment; the parser must read
1739        // `current_funding_rate` instead. Setting distinct sentinel values
1740        // catches a field-swap regression.
1741        let sentinel = Decimal::from_str("0.0000123").unwrap();
1742        let other = Decimal::from_str("0.9999").unwrap();
1743        stats.current_funding_rate = sentinel;
1744        stats.funding_rate = other;
1745
1746        let update = parse_ws_funding_rate_update(
1747            &stats,
1748            &instrument,
1749            1_774_883_844_933,
1750            UnixNanos::from(1),
1751        )
1752        .unwrap();
1753
1754        assert_eq!(update.rate, sentinel);
1755    }
1756
1757    #[rstest]
1758    fn test_parse_ws_order_book_depth_preserves_sparse_levels() {
1759        let instrument = create_test_instrument();
1760        let depth =
1761            parse_ws_order_book_depth(&stub_book(), &instrument, 1774884082326, UnixNanos::from(1))
1762                .unwrap();
1763
1764        assert_eq!(depth.instrument_id, instrument.id());
1765        assert_eq!(depth.bids.len(), 1);
1766        assert_eq!(depth.asks.len(), 1);
1767        assert_eq!(depth.bids[0].price, Price::from("2064.30"));
1768        assert_eq!(depth.bids[0].size, Quantity::from("1.0392"));
1769        assert_eq!(depth.bids[0].side, OrderSide::Buy.into());
1770        assert_eq!(depth.bids[0].order_id, 0);
1771        assert_eq!(depth.asks[0].price, Price::from("2064.54"));
1772        assert_eq!(depth.asks[0].size, Quantity::from("0.3285"));
1773        assert_eq!(depth.asks[0].side, OrderSide::Sell.into());
1774        assert_eq!(depth.asks[0].order_id, 0);
1775        assert_eq!(depth.sequence, 9_182_390_020);
1776        assert_eq!(depth.bid_counts.as_slice(), &[1]);
1777        assert_eq!(depth.ask_counts.as_slice(), &[1]);
1778        assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
1779        assert_eq!(depth.ts_event, UnixNanos::from(1_774_884_082_326_000_000));
1780        assert_eq!(depth.ts_init, UnixNanos::from(1));
1781    }
1782
1783    #[rstest]
1784    fn test_parse_ws_trade_tick_delegates_trade_parser() {
1785        let instrument = create_test_instrument();
1786        let trade = LighterTrade {
1787            trade_id: 16164557907,
1788            trade_id_str: Some("16164557907".to_string()),
1789            tx_hash: "019f2b9c".to_string(),
1790            trade_type: LighterTradeType::Trade,
1791            market_id: 0,
1792            size: Decimal::from_str("0.1336").unwrap(),
1793            price: Decimal::from_str("2181.83").unwrap(),
1794            usd_amount: Decimal::from_str("291.492488").unwrap(),
1795            ask_id: 281476612587355,
1796            ask_id_str: Some("281476612587355".to_string()),
1797            bid_id: 562948334068259,
1798            bid_id_str: Some("562948334068259".to_string()),
1799            ask_client_id: 363283,
1800            ask_client_id_str: Some("363283".to_string()),
1801            bid_client_id: 23004521241,
1802            bid_client_id_str: Some("23004521241".to_string()),
1803            ask_account_id: 57890,
1804            bid_account_id: 317068,
1805            is_maker_ask: false,
1806            block_height: 198321831,
1807            timestamp: 1773854156654,
1808            taker_fee: Some(196),
1809            taker_position_size_before: None,
1810            taker_entry_quote_before: None,
1811            taker_initial_margin_fraction_before: None,
1812            taker_position_sign_changed: None,
1813            maker_fee: Some(28),
1814            maker_position_size_before: None,
1815            maker_entry_quote_before: None,
1816            maker_initial_margin_fraction_before: None,
1817            maker_position_sign_changed: None,
1818            transaction_time: 1773854156686065,
1819            ask_account_pnl: None,
1820            bid_account_pnl: None,
1821        };
1822
1823        let tick = parse_ws_trade_tick(&trade, &instrument, UnixNanos::from(1)).unwrap();
1824
1825        assert_eq!(tick.trade_id.to_string(), "16164557907");
1826        assert_eq!(tick.price, Price::from("2181.83"));
1827    }
1828
1829    fn account_id() -> AccountId {
1830        AccountId::from("LIGHTER-1234")
1831    }
1832
1833    fn stub_order(status: LighterOrderStatus) -> LighterOrder {
1834        LighterOrder {
1835            order_index: 281476929510110,
1836            client_order_index: 42,
1837            order_id: "281476929510110".to_string(),
1838            client_order_id: "42".to_string(),
1839            market_index: 0,
1840            owner_account_index: 1234,
1841            initial_base_amount: Decimal::from_str("0.0050").unwrap(),
1842            price: Decimal::from_str("2352.74").unwrap(),
1843            nonce: 9182390020,
1844            remaining_base_amount: Decimal::from_str("0.0030").unwrap(),
1845            is_ask: true,
1846            base_size: 50,
1847            base_price: 235274,
1848            filled_base_amount: Decimal::from_str("0.0020").unwrap(),
1849            filled_quote_amount: Decimal::from_str("4.705480").unwrap(),
1850            side: Some(LighterOrderSide::Sell),
1851            order_type: LighterOrderKind::Limit,
1852            time_in_force: LighterOrderTimeInForce::GoodTillTime,
1853            reduce_only: false,
1854            trigger_price: Decimal::ZERO,
1855            order_expiry: 1_780_360_584_479,
1856            status,
1857            trigger_status: LighterTriggerStatus::Na,
1858            trigger_time: 0,
1859            parent_order_index: 0,
1860            parent_order_id: "0".to_string(),
1861            to_trigger_order_id_0: "0".to_string(),
1862            to_trigger_order_id_1: "0".to_string(),
1863            to_cancel_order_id_0: "0".to_string(),
1864            integrator_fee_collector_index: "0".to_string(),
1865            integrator_taker_fee: Decimal::ZERO,
1866            integrator_maker_fee: Decimal::ZERO,
1867            block_height: 227_535_532,
1868            timestamp: 1_777_941_383_576,
1869            created_at: 1_777_941_383_576,
1870            updated_at: 1_777_941_383_900,
1871            transaction_time: 1_777_941_383_576_735,
1872        }
1873    }
1874
1875    fn stub_account_trade(
1876        account_index: i64,
1877        is_maker_ask: bool,
1878        user_is_bidder: bool,
1879    ) -> LighterTrade {
1880        LighterTrade {
1881            trade_id: 19_209_006_902,
1882            trade_id_str: Some("19209006902".to_string()),
1883            tx_hash: "000000128b1ee814".to_string(),
1884            trade_type: LighterTradeType::Trade,
1885            market_id: 0,
1886            size: Decimal::from_str("0.1336").unwrap(),
1887            price: Decimal::from_str("2352.73").unwrap(),
1888            usd_amount: Decimal::from_str("314.324728").unwrap(),
1889            ask_id: 281_476_929_510_102,
1890            ask_id_str: Some("281476929510102".to_string()),
1891            bid_id: 562_947_905_631_053,
1892            bid_id_str: Some("562947905631053".to_string()),
1893            ask_client_id: 0,
1894            ask_client_id_str: Some("0".to_string()),
1895            bid_client_id: 7_001_011_966,
1896            bid_client_id_str: Some("7001011966".to_string()),
1897            ask_account_id: if user_is_bidder {
1898                91_249
1899            } else {
1900                account_index
1901            },
1902            bid_account_id: if user_is_bidder {
1903                account_index
1904            } else {
1905                91_249
1906            },
1907            is_maker_ask,
1908            block_height: 227_535_535,
1909            timestamp: 1_777_941_384_181,
1910            taker_fee: Some(196),
1911            taker_position_size_before: None,
1912            taker_entry_quote_before: None,
1913            taker_initial_margin_fraction_before: None,
1914            taker_position_sign_changed: None,
1915            maker_fee: Some(28),
1916            maker_position_size_before: None,
1917            maker_entry_quote_before: None,
1918            maker_initial_margin_fraction_before: None,
1919            maker_position_sign_changed: None,
1920            transaction_time: 1_777_941_384_181_586,
1921            ask_account_pnl: None,
1922            bid_account_pnl: None,
1923        }
1924    }
1925
1926    #[rstest]
1927    fn test_parse_ws_order_status_report_partial_fill_promotes_status() {
1928        let instrument = create_test_instrument();
1929        let order = stub_order(LighterOrderStatus::Open);
1930
1931        let report =
1932            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1933                .unwrap();
1934
1935        assert_eq!(report.venue_order_id.to_string(), "281476929510110");
1936        assert_eq!(report.client_order_id.unwrap().to_string(), "42");
1937        assert_eq!(report.order_side, OrderSide::Sell.into());
1938        assert_eq!(report.order_type, OrderType::Limit);
1939        // Open + filled_qty > 0 must surface as PartiallyFilled.
1940        assert_eq!(report.order_status, OrderStatus::PartiallyFilled);
1941        assert_eq!(report.filled_qty, Quantity::from("0.0020"));
1942        assert_eq!(report.quantity, Quantity::from("0.0050"));
1943        assert_eq!(report.price, Some(Price::from("2352.74")));
1944        assert_eq!(report.trigger_price, None);
1945        assert_eq!(report.time_in_force, TimeInForce::Gtd);
1946        assert!(report.expire_time.is_some());
1947        assert_eq!(report.ts_init, UnixNanos::from(7));
1948        assert_eq!(
1949            report.ts_accepted,
1950            UnixNanos::from(1_777_941_383_576_000_000),
1951        );
1952        assert_eq!(report.ts_last, UnixNanos::from(1_777_941_383_900_000_000));
1953    }
1954
1955    #[rstest]
1956    fn test_parse_ws_order_status_report_normalizes_second_scale_timestamps() {
1957        let instrument = create_test_instrument();
1958        // Mainnet order frames deliver `created_at`/`updated_at` in seconds,
1959        // while other frame sources use milliseconds.
1960        let mut order = stub_order(LighterOrderStatus::Open);
1961        order.timestamp = 1_777_941_383;
1962        order.created_at = 1_777_941_383;
1963        order.updated_at = 1_777_941_383;
1964
1965        let report =
1966            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1967                .unwrap();
1968
1969        assert_eq!(
1970            report.ts_accepted,
1971            UnixNanos::from(1_777_941_383_000_000_000),
1972        );
1973        assert_eq!(report.ts_last, UnixNanos::from(1_777_941_383_000_000_000),);
1974    }
1975
1976    #[rstest]
1977    fn test_parse_ws_order_status_report_pending_is_acknowledged() {
1978        let instrument = create_test_instrument();
1979        let mut order = stub_order(LighterOrderStatus::Pending);
1980        order.filled_base_amount = Decimal::ZERO;
1981
1982        let report =
1983            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
1984                .unwrap();
1985
1986        assert_eq!(report.order_status, OrderStatus::Accepted);
1987    }
1988
1989    #[rstest]
1990    fn test_parse_ws_order_status_report_post_only_cancel_is_rejected() {
1991        let instrument = create_test_instrument();
1992        let order = stub_order(LighterOrderStatus::CanceledPostOnly);
1993
1994        let report =
1995            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
1996                .unwrap();
1997
1998        assert_eq!(report.order_status, OrderStatus::Rejected);
1999        assert_eq!(report.cancel_reason.as_deref(), Some("post-only"));
2000    }
2001
2002    #[rstest]
2003    fn test_parse_ws_order_status_report_falls_back_to_is_ask() {
2004        let instrument = create_test_instrument();
2005        let mut order = stub_order(LighterOrderStatus::Open);
2006        order.side = None;
2007        order.is_ask = false;
2008
2009        let report =
2010            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
2011                .unwrap();
2012
2013        assert_eq!(report.order_side, OrderSide::Buy.into());
2014    }
2015
2016    #[rstest]
2017    #[case(LighterOrderKind::StopLossLimit, LighterTriggerStatus::Na)]
2018    #[case(LighterOrderKind::TakeProfitLimit, LighterTriggerStatus::Na)]
2019    #[case(LighterOrderKind::StopLossLimit, LighterTriggerStatus::Ready)]
2020    #[case(LighterOrderKind::StopLoss, LighterTriggerStatus::Na)]
2021    #[case(LighterOrderKind::TakeProfit, LighterTriggerStatus::Na)]
2022    #[case(LighterOrderKind::StopLoss, LighterTriggerStatus::Ready)]
2023    fn conditional_open_reports_and_events_normalize_trigger(
2024        #[case] kind: LighterOrderKind,
2025        #[case] trigger_status: LighterTriggerStatus,
2026    ) {
2027        let instrument = create_test_instrument();
2028        let mut order = stub_order(LighterOrderStatus::Open);
2029        order.order_type = kind;
2030        order.filled_base_amount = Decimal::ZERO;
2031        order.trigger_status = trigger_status;
2032        order.trigger_price = Decimal::from(2300);
2033        let report =
2034            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
2035                .unwrap();
2036
2037        let event = parse_lighter_order_event(
2038            &order,
2039            &instrument,
2040            &test_identity_for(report.order_type),
2041            test_cloid(),
2042            account_id(),
2043            test_trader_id(),
2044            OpenFrameContext {
2045                accepted_already_emitted: true,
2046                triggered_already_emitted: false,
2047                shape_changed: false,
2048            },
2049            UnixNanos::from(7),
2050        )
2051        .unwrap();
2052
2053        let replay = parse_lighter_order_event(
2054            &order,
2055            &instrument,
2056            &test_identity_for(report.order_type),
2057            test_cloid(),
2058            account_id(),
2059            test_trader_id(),
2060            OpenFrameContext {
2061                accepted_already_emitted: true,
2062                triggered_already_emitted: true,
2063                shape_changed: false,
2064            },
2065            UnixNanos::from(8),
2066        )
2067        .unwrap();
2068
2069        if TRIGGERABLE_ORDER_TYPES.contains(&report.order_type) {
2070            assert_eq!(report.order_status, OrderStatus::Triggered);
2071            assert!(
2072                matches!(event, Some(ParsedOrderEvent::Triggered(triggered)) if triggered.client_order_id == test_cloid())
2073            );
2074        } else {
2075            assert_eq!(report.order_status, OrderStatus::Accepted);
2076            assert!(event.is_none());
2077        }
2078
2079        assert!(replay.is_none());
2080    }
2081
2082    #[rstest]
2083    fn triggered_external_limit_without_trigger_price_reports_active_limit() {
2084        let instrument = create_test_instrument();
2085        let mut order = stub_order(LighterOrderStatus::Open);
2086        order.order_type = LighterOrderKind::TakeProfitLimit;
2087        order.filled_base_amount = Decimal::ZERO;
2088        order.trigger_status = LighterTriggerStatus::Na;
2089        order.trigger_price = Decimal::ZERO;
2090        let report =
2091            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(7))
2092                .unwrap();
2093        assert_eq!(report.order_type, OrderType::Limit);
2094        assert_eq!(report.order_status, OrderStatus::Accepted);
2095        assert_eq!(report.trigger_price, None);
2096        assert_eq!(report.price, Some(Price::from("2352.74")));
2097    }
2098
2099    #[rstest]
2100    fn test_parse_ws_order_status_report_omits_parent_order_id() {
2101        // Lighter's `parent_order_id` lives in the venue namespace; populating
2102        // Nautilus `OrderStatusReport::parent_order_id` (a `ClientOrderId`)
2103        // from it would mislabel namespaces. This guards against re-introducing
2104        // that mapping.
2105        let instrument = create_test_instrument();
2106        let mut order = stub_order(LighterOrderStatus::Open);
2107        order.parent_order_id = "999999".to_string();
2108        order.parent_order_index = 999_999;
2109
2110        let report =
2111            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2112                .unwrap();
2113
2114        assert!(report.parent_order_id.is_none());
2115        assert_eq!(report.contingency_type, None);
2116    }
2117
2118    #[rstest]
2119    #[case::stop_market(LighterOrderKind::StopLoss, OrderType::StopMarket)]
2120    #[case::stop_limit(LighterOrderKind::StopLossLimit, OrderType::StopLimit)]
2121    #[case::market_if_touched(LighterOrderKind::TakeProfit, OrderType::MarketIfTouched)]
2122    #[case::limit_if_touched(LighterOrderKind::TakeProfitLimit, OrderType::LimitIfTouched)]
2123    fn test_parse_ws_order_status_report_conditional_sets_default_trigger_type(
2124        #[case] lighter_order_type: LighterOrderKind,
2125        #[case] expected_order_type: OrderType,
2126    ) {
2127        let instrument = create_test_instrument();
2128        let mut order = stub_order(LighterOrderStatus::Open);
2129        order.order_type = lighter_order_type;
2130        order.trigger_price = Decimal::from_str("2200.00").unwrap();
2131
2132        let report =
2133            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2134                .unwrap();
2135
2136        assert_eq!(report.order_type, expected_order_type);
2137        assert_eq!(report.trigger_price, Some(Price::from("2200.00")));
2138        assert_eq!(report.trigger_type, Some(TriggerType::Default));
2139    }
2140
2141    #[rstest]
2142    #[case::ioc(
2143        LighterOrderTimeInForce::ImmediateOrCancel,
2144        0,
2145        TimeInForce::Ioc,
2146        None,
2147        false
2148    )]
2149    #[case::post_only_negative_expiry(
2150        LighterOrderTimeInForce::PostOnly,
2151        -1,
2152        TimeInForce::Gtc,
2153        None,
2154        true
2155    )]
2156    #[case::post_only_zero_expiry(
2157        LighterOrderTimeInForce::PostOnly,
2158        0,
2159        TimeInForce::Gtc,
2160        None,
2161        true
2162    )]
2163    #[case::post_only_positive_expiry(
2164        LighterOrderTimeInForce::PostOnly,
2165        1_780_000_000_000,
2166        TimeInForce::Gtd,
2167        Some(UnixNanos::from(1_780_000_000_000_000_000_u64)),
2168        true
2169    )]
2170    #[case::gtt_negative_expiry(LighterOrderTimeInForce::GoodTillTime, -1, TimeInForce::Gtc, None, false)]
2171    #[case::gtt_zero_expiry(
2172        LighterOrderTimeInForce::GoodTillTime,
2173        0,
2174        TimeInForce::Gtc,
2175        None,
2176        false
2177    )]
2178    #[case::gtt_positive_expiry(
2179        LighterOrderTimeInForce::GoodTillTime,
2180        1_780_000_000_000,
2181        TimeInForce::Gtd,
2182        Some(UnixNanos::from(1_780_000_000_000_000_000_u64)),
2183        false
2184    )]
2185    #[case::unknown(LighterOrderTimeInForce::Unknown, 0, TimeInForce::Gtc, None, false)]
2186    fn test_parse_ws_order_status_report_time_in_force_matrix(
2187        #[case] tif: LighterOrderTimeInForce,
2188        #[case] order_expiry: i64,
2189        #[case] expected_tif: TimeInForce,
2190        #[case] expected_expire_time: Option<UnixNanos>,
2191        #[case] expected_post_only: bool,
2192    ) {
2193        let instrument = create_test_instrument();
2194        let mut order = stub_order(LighterOrderStatus::Open);
2195        order.time_in_force = tif;
2196        order.order_expiry = order_expiry;
2197
2198        let report =
2199            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2200                .unwrap();
2201
2202        assert_eq!(report.time_in_force, expected_tif);
2203        assert_eq!(report.expire_time, expected_expire_time);
2204        assert_eq!(report.post_only, expected_post_only);
2205    }
2206
2207    #[rstest]
2208    #[case::active(LighterOrderStatus::Open, OrderStatus::Accepted)]
2209    #[case::terminal(LighterOrderStatus::Canceled, OrderStatus::Canceled)]
2210    fn test_parse_ws_post_only_expiry_is_consistent_across_statuses(
2211        #[case] status: LighterOrderStatus,
2212        #[case] expected_status: OrderStatus,
2213    ) {
2214        let instrument = create_test_instrument();
2215        let mut order = stub_order(status);
2216        order.time_in_force = LighterOrderTimeInForce::PostOnly;
2217        order.order_expiry = 1_780_000_000_000;
2218        order.filled_base_amount = Decimal::ZERO;
2219
2220        let report =
2221            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2222                .unwrap();
2223
2224        assert_eq!(report.order_status, expected_status);
2225        assert_eq!(report.time_in_force, TimeInForce::Gtd);
2226        assert_eq!(
2227            report.expire_time,
2228            Some(UnixNanos::from(1_780_000_000_000_000_000_u64))
2229        );
2230        assert!(report.post_only);
2231    }
2232
2233    #[rstest]
2234    fn test_parse_ws_order_status_report_rejects_twap() {
2235        let instrument = create_test_instrument();
2236        let mut order = stub_order(LighterOrderStatus::Open);
2237        order.order_type = LighterOrderKind::Twap;
2238
2239        let err =
2240            parse_ws_order_status_report(&order, &instrument, account_id(), UnixNanos::from(1))
2241                .unwrap_err();
2242
2243        assert!(
2244            err.to_string()
2245                .contains("no Nautilus order-type equivalent")
2246        );
2247    }
2248
2249    // Liquidity side is decided by `user_is_asker == trade.is_maker_ask`:
2250    // when the user sat on the same side that rested as the maker, they
2251    // were the maker; otherwise they crossed the book as the taker. The
2252    // four combinations exhaustively cover that branch.
2253    #[rstest]
2254    #[case::bidder_maker_ask_is_taker(
2255        true,
2256        true,
2257        OrderSide::Buy,
2258        LiquiditySide::Taker,
2259        "0.06160765 USDC"
2260    )]
2261    #[case::asker_maker_ask_is_maker(
2262        false,
2263        true,
2264        OrderSide::Sell,
2265        LiquiditySide::Maker,
2266        "0.00880109 USDC"
2267    )]
2268    #[case::bidder_maker_bid_is_maker(
2269        true,
2270        false,
2271        OrderSide::Buy,
2272        LiquiditySide::Maker,
2273        "0.00880109 USDC"
2274    )]
2275    #[case::asker_maker_bid_is_taker(
2276        false,
2277        false,
2278        OrderSide::Sell,
2279        LiquiditySide::Taker,
2280        "0.06160765 USDC"
2281    )]
2282    fn test_parse_ws_fill_report_liquidity_side_matrix(
2283        #[case] user_is_bidder: bool,
2284        #[case] is_maker_ask: bool,
2285        #[case] expected_side: OrderSide,
2286        #[case] expected_liquidity: LiquiditySide,
2287        #[case] expected_commission: &str,
2288    ) {
2289        let instrument = create_test_instrument();
2290        let trade = stub_account_trade(1234, is_maker_ask, user_is_bidder);
2291
2292        let report =
2293            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(9))
2294                .unwrap()
2295                .expect("user-side fill");
2296
2297        assert_eq!(report.order_side, expected_side);
2298        assert_eq!(report.liquidity_side, expected_liquidity);
2299        assert_eq!(report.last_qty, Quantity::from("0.1336"));
2300        assert_eq!(report.last_px, Price::from("2352.73"));
2301        assert_eq!(report.commission, Money::from(expected_commission));
2302        let expected_voi = if user_is_bidder {
2303            "562947905631053"
2304        } else {
2305            "281476929510102"
2306        };
2307        assert_eq!(report.venue_order_id.to_string(), expected_voi);
2308    }
2309
2310    #[rstest]
2311    #[case::larger_size("0.2672", "2352.73", Some(196), "0.12321529 USDC")]
2312    #[case::higher_price("0.1336", "4705.46", Some(196), "0.12321529 USDC")]
2313    #[case::small_notional("0.0001", "2352.73", Some(196), "0.00004611 USDC")]
2314    #[case::missing_fee("0.1336", "2352.73", None, "0 USDC")]
2315    #[case::zero_fee("0.1336", "2352.73", Some(0), "0 USDC")]
2316    fn test_fill_commission_scales_with_notional(
2317        #[case] size: &str,
2318        #[case] price: &str,
2319        #[case] fee: Option<i32>,
2320        #[case] expected: &str,
2321    ) {
2322        let instrument = create_test_instrument();
2323        let mut trade = stub_account_trade(1234, true, true);
2324        trade.size = Decimal::from_str(size).unwrap();
2325        trade.price = Decimal::from_str(price).unwrap();
2326        trade.taker_fee = fee;
2327
2328        let report =
2329            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2330                .unwrap()
2331                .expect("user-side fill");
2332        let filled = parse_lighter_order_filled(
2333            &trade,
2334            &instrument,
2335            &test_identity(),
2336            test_cloid(),
2337            account_id(),
2338            test_trader_id(),
2339            1234,
2340            UnixNanos::from(1),
2341        )
2342        .unwrap()
2343        .expect("user-side fill");
2344
2345        assert_eq!(report.commission, Money::from(expected));
2346        assert_eq!(filled.commission, Some(report.commission));
2347    }
2348
2349    #[rstest]
2350    fn test_lighter_fee_to_commission_rejects_overflow() {
2351        let mut trade = stub_account_trade(1234, true, true);
2352        trade.size = Decimal::MAX;
2353        trade.price = Decimal::MAX;
2354
2355        let err = lighter_fee_to_commission(Some(196), &trade, Currency::USDC()).unwrap_err();
2356
2357        assert_eq!(err.detail, "overflow calculating fee amount");
2358    }
2359
2360    #[rstest]
2361    fn test_parse_ws_fill_report_skips_other_accounts() {
2362        let instrument = create_test_instrument();
2363        let trade = stub_account_trade(9999, false, true);
2364
2365        let report =
2366            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2367                .unwrap();
2368
2369        assert!(report.is_none());
2370    }
2371
2372    // When the venue drops the `*_id_str` field (the typed numeric fields are
2373    // always populated), the parser must fall back to stringifying the i64
2374    // mirror to seed VenueOrderId. Pins the numeric-fallback branch in
2375    // `venue_order_id_from`; the matrix above always populates the strings.
2376    #[rstest]
2377    fn test_parse_ws_fill_report_venue_order_id_falls_back_to_numeric() {
2378        let instrument = create_test_instrument();
2379        let mut trade = stub_account_trade(1234, false, true);
2380        trade.bid_id_str = None;
2381        trade.ask_id_str = None;
2382
2383        let report =
2384            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2385                .unwrap()
2386                .expect("user-side fill");
2387
2388        // user_is_bidder=true => VenueOrderId derives from bid_id.
2389        assert_eq!(report.venue_order_id.to_string(), "562947905631053");
2390    }
2391
2392    // Same fallback contract for `client_order_id_from`: when `*_client_id_str`
2393    // is absent but the numeric mirror is non-zero, the cloid must surface as
2394    // the numeric value; when both string is absent and numeric is zero, the
2395    // cloid is `None`.
2396    #[rstest]
2397    fn test_parse_ws_fill_report_client_order_id_falls_back_to_numeric() {
2398        let instrument = create_test_instrument();
2399        let mut trade = stub_account_trade(1234, false, true);
2400        trade.bid_client_id_str = None;
2401        trade.ask_client_id_str = None;
2402
2403        let report =
2404            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2405                .unwrap()
2406                .expect("user-side fill");
2407
2408        // user_is_bidder=true with bid_client_id=7_001_011_966 (non-zero).
2409        assert_eq!(report.client_order_id.unwrap().to_string(), "7001011966");
2410    }
2411
2412    #[rstest]
2413    fn test_parse_ws_fill_report_client_order_id_absent_when_zero_numeric() {
2414        let instrument = create_test_instrument();
2415        // user_is_bidder=false => looks at ask_client_id, which is 0 in the stub.
2416        let mut trade = stub_account_trade(1234, true, false);
2417        trade.ask_client_id_str = None;
2418
2419        let report =
2420            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2421                .unwrap()
2422                .expect("user-side fill");
2423
2424        assert!(report.client_order_id.is_none());
2425    }
2426
2427    #[rstest]
2428    fn test_parse_ws_fill_report_client_order_id_absent_when_string_is_zero_sentinel() {
2429        let instrument = create_test_instrument();
2430        // user_is_bidder=false => looks at ask_client_id_str, which is "0" in the stub.
2431        let trade = stub_account_trade(1234, true, false);
2432
2433        let report =
2434            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2435                .unwrap()
2436                .expect("user-side fill");
2437
2438        assert!(report.client_order_id.is_none());
2439    }
2440
2441    #[rstest]
2442    #[case::missing(None)]
2443    #[case::zero(Some(0))]
2444    fn test_parse_ws_fill_report_handles_zero_fee(#[case] fee: Option<i32>) {
2445        let instrument = create_test_instrument();
2446        let mut trade = stub_account_trade(1234, true, true);
2447        trade.taker_fee = fee;
2448        trade.maker_fee = fee;
2449
2450        let report =
2451            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2452                .unwrap()
2453                .expect("user-side fill");
2454
2455        assert_eq!(report.commission, Money::from("0 USDC"));
2456    }
2457
2458    #[rstest]
2459    fn test_parse_ws_fill_report_uses_instrument_quote_currency_for_fee() {
2460        let currency = Currency::USDG();
2461        let instrument =
2462            create_test_instrument_with_quote(Venue::new("LIGHTER_ROBINHOOD"), currency);
2463
2464        let trade = stub_account_trade(1234, true, true);
2465
2466        let report =
2467            parse_ws_fill_report(&trade, 1234, &instrument, account_id(), UnixNanos::from(1))
2468                .unwrap()
2469                .expect("user-side fill");
2470
2471        assert_eq!(report.commission, Money::from("0.06160765 USDG"));
2472    }
2473
2474    #[rstest]
2475    fn test_parse_ws_position_status_report_long_position() {
2476        let instrument = create_test_instrument();
2477        let position = LighterPosition {
2478            market_id: 0,
2479            symbol: Ustr::from("ETH"),
2480            initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2481            open_order_count: 1,
2482            pending_order_count: 0,
2483            position_tied_order_count: 0,
2484            sign: 1,
2485            position: Decimal::from_str("1.5000").unwrap(),
2486            avg_entry_price: Decimal::from_str("2350.10").unwrap(),
2487            position_value: Decimal::from_str("3525.15").unwrap(),
2488            unrealized_pnl: Decimal::from_str("3.45").unwrap(),
2489            realized_pnl: Decimal::ZERO,
2490            liquidation_price: Decimal::from_str("1900.00").unwrap(),
2491            total_funding_paid_out: Some(Decimal::from_str("0.05").unwrap()),
2492            margin_mode: 0,
2493            allocated_margin: Decimal::from_str("176.25").unwrap(),
2494            total_discount: Some(Decimal::ZERO),
2495        };
2496
2497        let report = parse_ws_position_status_report(
2498            &position,
2499            &instrument,
2500            account_id(),
2501            UnixNanos::from(50),
2502            UnixNanos::from(50),
2503        )
2504        .unwrap();
2505
2506        assert_eq!(report.position_side, PositionSide::Long);
2507        assert_eq!(report.quantity, Quantity::from("1.5000"));
2508        assert_eq!(report.signed_decimal_qty, Decimal::new(15, 1));
2509        assert_eq!(report.avg_px_open, Some(Decimal::new(235010, 2)));
2510        assert!(report.venue_position_id.is_none());
2511    }
2512
2513    #[rstest]
2514    fn test_parse_ws_position_status_report_short_position() {
2515        let instrument = create_test_instrument();
2516        let position = LighterPosition {
2517            market_id: 0,
2518            symbol: Ustr::from("ETH"),
2519            initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2520            open_order_count: 0,
2521            pending_order_count: 0,
2522            position_tied_order_count: 0,
2523            sign: -1,
2524            position: Decimal::from_str("0.7500").unwrap(),
2525            avg_entry_price: Decimal::from_str("2400.00").unwrap(),
2526            position_value: Decimal::from_str("1800.00").unwrap(),
2527            unrealized_pnl: Decimal::ZERO,
2528            realized_pnl: Decimal::ZERO,
2529            liquidation_price: Decimal::from_str("3000.00").unwrap(),
2530            total_funding_paid_out: None,
2531            margin_mode: 0,
2532            allocated_margin: Decimal::from_str("90.00").unwrap(),
2533            total_discount: None,
2534        };
2535
2536        let report = parse_ws_position_status_report(
2537            &position,
2538            &instrument,
2539            account_id(),
2540            UnixNanos::default(),
2541            UnixNanos::default(),
2542        )
2543        .unwrap();
2544
2545        assert_eq!(report.position_side, PositionSide::Short);
2546        assert_eq!(report.quantity, Quantity::from("0.7500"));
2547        assert_eq!(report.signed_decimal_qty, Decimal::new(-75, 2));
2548    }
2549
2550    #[rstest]
2551    fn test_parse_ws_position_status_report_flat_position() {
2552        let instrument = create_test_instrument();
2553        let position = LighterPosition {
2554            market_id: 0,
2555            symbol: Ustr::from("ETH"),
2556            initial_margin_fraction: Decimal::from_str("0.0500").unwrap(),
2557            open_order_count: 0,
2558            pending_order_count: 0,
2559            position_tied_order_count: 0,
2560            sign: 0,
2561            position: Decimal::ZERO,
2562            avg_entry_price: Decimal::ZERO,
2563            position_value: Decimal::ZERO,
2564            unrealized_pnl: Decimal::ZERO,
2565            realized_pnl: Decimal::ZERO,
2566            liquidation_price: Decimal::ZERO,
2567            total_funding_paid_out: None,
2568            margin_mode: 0,
2569            allocated_margin: Decimal::ZERO,
2570            total_discount: None,
2571        };
2572
2573        let report = parse_ws_position_status_report(
2574            &position,
2575            &instrument,
2576            account_id(),
2577            UnixNanos::default(),
2578            UnixNanos::default(),
2579        )
2580        .unwrap();
2581
2582        assert_eq!(report.position_side, PositionSide::Flat);
2583        assert!(report.quantity.is_zero());
2584        assert_eq!(report.signed_decimal_qty, Decimal::ZERO);
2585        assert!(report.avg_px_open.is_none());
2586    }
2587
2588    #[rstest]
2589    fn test_account_balance_from_lighter_asset_spot_only() {
2590        // Asset with only a spot balance (margin_balance=0): perp leg is
2591        // empty so total == spot balance, locked == locked_balance only.
2592        let asset = LighterAsset {
2593            symbol: Ustr::from("USDC"),
2594            asset_id: 0,
2595            balance: Decimal::from_str("100.000000").unwrap(),
2596            locked_balance: Decimal::from_str("1.000000").unwrap(),
2597            margin_balance: Decimal::ZERO,
2598            margin_mode: Ustr::default(),
2599        };
2600
2601        let balance = account_balance_from_lighter_asset(&asset).unwrap();
2602        let usdc = Currency::get_or_create_crypto("USDC");
2603        assert_eq!(balance.currency, usdc);
2604        assert_eq!(balance.total, Money::from("100.000000 USDC"));
2605        assert_eq!(balance.locked, Money::from("1.000000 USDC"));
2606        assert_eq!(balance.free, Money::from("99.000000 USDC"));
2607    }
2608
2609    #[rstest]
2610    fn test_account_balance_from_lighter_asset_combines_spot_and_perp() {
2611        // Worked example: 10 USDC sitting on spot, 40 USDC pledged as
2612        // perp collateral, no resting spot orders. Lighter runs unified
2613        // margin: both legs are deployable equity, so the merged view
2614        // is total=50, locked=0, free=50. Perp margin currently in use
2615        // is tracked separately via MarginBalance, not via locked here.
2616        let asset = LighterAsset {
2617            symbol: Ustr::from("USDC"),
2618            asset_id: 3,
2619            balance: Decimal::from_str("10.000000").unwrap(),
2620            locked_balance: Decimal::ZERO,
2621            margin_balance: Decimal::from_str("40.000000").unwrap(),
2622            margin_mode: Ustr::from("disabled"),
2623        };
2624
2625        let balance = account_balance_from_lighter_asset(&asset).unwrap();
2626        assert_eq!(balance.total, Money::from("50.000000 USDC"));
2627        assert_eq!(balance.locked, Money::from("0 USDC"));
2628        assert_eq!(balance.free, Money::from("50.000000 USDC"));
2629    }
2630
2631    #[rstest]
2632    fn test_account_balance_from_lighter_asset_locks_only_spot_order_reservation() {
2633        // A resting spot limit order locks 1 USDC. total still reflects
2634        // both legs (10 + 40 = 50); locked tracks the spot reservation
2635        // only, free = 49.
2636        let asset = LighterAsset {
2637            symbol: Ustr::from("USDC"),
2638            asset_id: 3,
2639            balance: Decimal::from_str("10.000000").unwrap(),
2640            locked_balance: Decimal::from_str("1.000000").unwrap(),
2641            margin_balance: Decimal::from_str("40.000000").unwrap(),
2642            margin_mode: Ustr::from("disabled"),
2643        };
2644
2645        let balance = account_balance_from_lighter_asset(&asset).unwrap();
2646        assert_eq!(balance.total, Money::from("50.000000 USDC"));
2647        assert_eq!(balance.locked, Money::from("1.000000 USDC"));
2648        assert_eq!(balance.free, Money::from("49.000000 USDC"));
2649    }
2650
2651    #[rstest]
2652    fn test_margin_balance_from_user_stats_no_positions() {
2653        // 40 USDC collateral, 40 available, no positions open: both initial
2654        // and maintenance must be zero; strategies should see "full
2655        // collateral free to deploy".
2656        let stats = LighterUserStats {
2657            account_trading_mode: 0,
2658            available_balance: Decimal::from_str("40.000000").unwrap(),
2659            buying_power: Decimal::ZERO,
2660            collateral: Decimal::from_str("40.000000").unwrap(),
2661            leverage: Decimal::ZERO,
2662            margin_usage: Decimal::ZERO,
2663            portfolio_value: Decimal::from_str("40.000000").unwrap(),
2664            cross_stats: None,
2665            total_stats: None,
2666        };
2667
2668        let margin = margin_balance_from_user_stats(&stats).unwrap();
2669        let usdc = Currency::get_or_create_crypto("USDC");
2670        assert_eq!(margin.currency, usdc);
2671        assert_eq!(margin.initial, Money::from("0 USDC"));
2672        assert_eq!(margin.maintenance, Money::from("0 USDC"));
2673        assert_eq!(margin.instrument_id, None);
2674    }
2675
2676    #[rstest]
2677    fn test_margin_balance_from_user_stats_with_position() {
2678        // 40 USDC collateral, 35 available -> 5 USDC initial margin in use.
2679        // Maintenance is always 0 here: Lighter's `margin_usage` is an
2680        // initial-margin-usage percent, not a maintenance ratio, so we
2681        // don't derive maintenance from `user_stats` at all (see comment
2682        // on `margin_balance_from_user_stats`).
2683        let stats = LighterUserStats {
2684            account_trading_mode: 0,
2685            available_balance: Decimal::from_str("35.000000").unwrap(),
2686            buying_power: Decimal::from_str("100.000000").unwrap(),
2687            collateral: Decimal::from_str("40.000000").unwrap(),
2688            leverage: Decimal::from_str("5.00").unwrap(),
2689            margin_usage: Decimal::from_str("12.50").unwrap(),
2690            portfolio_value: Decimal::from_str("40.000000").unwrap(),
2691            cross_stats: None,
2692            total_stats: None,
2693        };
2694
2695        let margin = margin_balance_from_user_stats(&stats).unwrap();
2696        assert_eq!(margin.initial, Money::from("5.000000 USDC"));
2697        assert_eq!(margin.maintenance, Money::from("0 USDC"));
2698    }
2699
2700    #[rstest]
2701    fn test_build_unified_account_state_emits_margin_account() {
2702        let asset = LighterAsset {
2703            symbol: Ustr::from("USDC"),
2704            asset_id: 3,
2705            balance: Decimal::from_str("10.000000").unwrap(),
2706            locked_balance: Decimal::ZERO,
2707            margin_balance: Decimal::from_str("40.000000").unwrap(),
2708            margin_mode: Ustr::from("disabled"),
2709        };
2710        let balances = vec![account_balance_from_lighter_asset(&asset).unwrap()];
2711
2712        let stats = LighterUserStats {
2713            account_trading_mode: 0,
2714            available_balance: Decimal::from_str("40.000000").unwrap(),
2715            buying_power: Decimal::ZERO,
2716            collateral: Decimal::from_str("40.000000").unwrap(),
2717            leverage: Decimal::ZERO,
2718            margin_usage: Decimal::ZERO,
2719            portfolio_value: Decimal::from_str("40.000000").unwrap(),
2720            cross_stats: None,
2721            total_stats: None,
2722        };
2723        let margin = margin_balance_from_user_stats(&stats).unwrap();
2724
2725        let state = build_unified_account_state(
2726            balances,
2727            Some(margin),
2728            account_id(),
2729            UnixNanos::from(1_000),
2730            UnixNanos::from(1_001),
2731        );
2732
2733        let usdc = Currency::get_or_create_crypto("USDC");
2734        assert_eq!(state.account_id, account_id());
2735        assert_eq!(state.account_type, AccountType::Margin);
2736        assert_eq!(state.base_currency, None);
2737        assert!(state.is_reported);
2738        assert_eq!(state.balances.len(), 1);
2739        assert_eq!(state.balances[0].total, Money::from("50.000000 USDC"));
2740        assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2741        assert_eq!(state.balances[0].free, Money::from("50.000000 USDC"));
2742        assert_eq!(state.margins.len(), 1);
2743        assert_eq!(state.margins[0].currency, usdc);
2744        assert_eq!(state.margins[0].initial, Money::from("0 USDC"));
2745        assert!(state.margins[0].instrument_id.is_none());
2746    }
2747
2748    fn stub_candle() -> LighterWsCandle {
2749        LighterWsCandle {
2750            t: 1_778_821_440_000,
2751            o: Decimal::new(226_420, 2),
2752            h: Decimal::new(226_434, 2),
2753            l: Decimal::new(226_336, 2),
2754            c: Decimal::new(226_397, 2),
2755            v: Decimal::new(132_237, 4),
2756            quote_volume: Decimal::ZERO,
2757            i: 0,
2758        }
2759    }
2760
2761    #[rstest]
2762    fn test_parse_ws_bar_emits_open_timestamp_and_external_last_spec() {
2763        let instrument = create_test_instrument();
2764        let candle = stub_candle();
2765
2766        let bar = parse_ws_bar(
2767            &instrument,
2768            &candle,
2769            LighterCandleResolution::OneMinute,
2770            UnixNanos::from(99_999),
2771        )
2772        .unwrap();
2773
2774        assert_eq!(bar.bar_type.instrument_id(), instrument.id());
2775        assert_eq!(bar.bar_type.spec().step.get(), 1);
2776        assert_eq!(bar.bar_type.spec().aggregation, BarAggregation::Minute);
2777        assert_eq!(bar.bar_type.spec().price_type, PriceType::Last);
2778        assert_eq!(
2779            bar.bar_type.aggregation_source(),
2780            AggregationSource::External
2781        );
2782        assert_eq!(bar.open, Price::from("2264.20"));
2783        assert_eq!(bar.high, Price::from("2264.34"));
2784        assert_eq!(bar.low, Price::from("2263.36"));
2785        assert_eq!(bar.close, Price::from("2263.97"));
2786        assert_eq!(bar.volume, Quantity::from("13.2237"));
2787        assert_eq!(bar.ts_event, UnixNanos::from(1_778_821_440_000_000_000));
2788        assert_eq!(bar.ts_init, UnixNanos::from(99_999));
2789    }
2790
2791    #[rstest]
2792    fn test_parse_ws_bar_rejects_negative_timestamp() {
2793        let instrument = create_test_instrument();
2794        let mut candle = stub_candle();
2795        candle.t = -1;
2796
2797        let err = parse_ws_bar(
2798            &instrument,
2799            &candle,
2800            LighterCandleResolution::OneMinute,
2801            UnixNanos::default(),
2802        )
2803        .unwrap_err();
2804
2805        assert!(
2806            err.to_string().contains("negative candle timestamp"),
2807            "expected negative-timestamp error, was: {err}",
2808        );
2809    }
2810
2811    fn test_identity() -> OrderIdentity {
2812        test_identity_for(OrderType::Limit)
2813    }
2814
2815    fn test_identity_for(order_type: OrderType) -> OrderIdentity {
2816        OrderIdentity::new(
2817            create_test_instrument().id(),
2818            StrategyId::new("S-TEST"),
2819            OrderSide::Sell,
2820            order_type,
2821            1,
2822        )
2823    }
2824
2825    fn test_trader_id() -> TraderId {
2826        TraderId::new("TRADER-001")
2827    }
2828
2829    fn test_cloid() -> ClientOrderId {
2830        ClientOrderId::new("MY-ORDER-001")
2831    }
2832
2833    #[rstest]
2834    fn parse_lighter_order_event_emits_accepted_on_open() {
2835        let instrument = create_test_instrument();
2836        let mut order = stub_order(LighterOrderStatus::Open);
2837        order.filled_base_amount = Decimal::ZERO;
2838
2839        let event = parse_lighter_order_event(
2840            &order,
2841            &instrument,
2842            &test_identity(),
2843            test_cloid(),
2844            account_id(),
2845            test_trader_id(),
2846            OpenFrameContext {
2847                accepted_already_emitted: false,
2848                triggered_already_emitted: false,
2849                shape_changed: false,
2850            },
2851            UnixNanos::from(7),
2852        )
2853        .unwrap()
2854        .expect("Open with no prior accept emits Accepted");
2855
2856        match event {
2857            ParsedOrderEvent::Accepted(e) => {
2858                assert_eq!(e.client_order_id, test_cloid());
2859                assert_eq!(e.venue_order_id.to_string(), "281476929510110");
2860            }
2861            other => panic!("expected Accepted, was {other:?}"),
2862        }
2863    }
2864
2865    #[rstest]
2866    fn parse_lighter_order_event_emits_accepted_on_pending() {
2867        let instrument = create_test_instrument();
2868        let order = stub_order(LighterOrderStatus::Pending);
2869
2870        let event = parse_lighter_order_event(
2871            &order,
2872            &instrument,
2873            &test_identity(),
2874            test_cloid(),
2875            account_id(),
2876            test_trader_id(),
2877            OpenFrameContext {
2878                accepted_already_emitted: false,
2879                triggered_already_emitted: false,
2880                shape_changed: false,
2881            },
2882            UnixNanos::from(7),
2883        )
2884        .unwrap()
2885        .expect("Pending is venue-acknowledged and emits Accepted");
2886
2887        assert!(matches!(event, ParsedOrderEvent::Accepted(_)));
2888    }
2889
2890    #[rstest]
2891    fn parse_lighter_order_event_emits_updated_on_pending_shape_change() {
2892        let instrument = create_test_instrument();
2893        let mut order = stub_order(LighterOrderStatus::Pending);
2894        order.order_type = LighterOrderKind::StopLossLimit;
2895        order.trigger_price = Decimal::from_str("2300.00").unwrap();
2896
2897        let event = parse_lighter_order_event(
2898            &order,
2899            &instrument,
2900            &test_identity_for(OrderType::StopLimit),
2901            test_cloid(),
2902            account_id(),
2903            test_trader_id(),
2904            OpenFrameContext {
2905                accepted_already_emitted: true,
2906                triggered_already_emitted: false,
2907                shape_changed: true,
2908            },
2909            UnixNanos::from(7),
2910        )
2911        .unwrap()
2912        .expect("modified Pending order emits Updated");
2913
2914        match event {
2915            ParsedOrderEvent::Updated(event) => {
2916                assert_eq!(event.price, Some(Price::from("2352.74")));
2917                assert_eq!(event.trigger_price, Some(Price::from("2300.00")));
2918            }
2919            other => panic!("expected Updated, was {other:?}"),
2920        }
2921    }
2922
2923    #[rstest]
2924    fn parse_lighter_order_event_pending_ready_does_not_emit_triggered() {
2925        let instrument = create_test_instrument();
2926        let mut order = stub_order(LighterOrderStatus::Pending);
2927        order.trigger_status = LighterTriggerStatus::Ready;
2928
2929        let event = parse_lighter_order_event(
2930            &order,
2931            &instrument,
2932            &test_identity_for(OrderType::StopLimit),
2933            test_cloid(),
2934            account_id(),
2935            test_trader_id(),
2936            OpenFrameContext {
2937                accepted_already_emitted: false,
2938                triggered_already_emitted: false,
2939                shape_changed: false,
2940            },
2941            UnixNanos::from(7),
2942        )
2943        .unwrap()
2944        .expect("Pending Ready still emits its acknowledgement");
2945
2946        assert!(matches!(event, ParsedOrderEvent::Accepted(_)));
2947    }
2948
2949    #[rstest]
2950    fn parse_lighter_order_event_emits_updated_only_when_shape_changed() {
2951        // Lighter's modify-as-restate: the venue echoes the modified order
2952        // as `Open`. Updated must fire only when the shape (qty / price /
2953        // trigger) actually changed; otherwise repeat `Open` echoes
2954        // (partial-fill snapshots, reconnect replays) would mint spurious
2955        // modify events.
2956        let instrument = create_test_instrument();
2957        let order = stub_order(LighterOrderStatus::Open);
2958
2959        let event = parse_lighter_order_event(
2960            &order,
2961            &instrument,
2962            &test_identity(),
2963            test_cloid(),
2964            account_id(),
2965            test_trader_id(),
2966            OpenFrameContext {
2967                accepted_already_emitted: true,
2968                triggered_already_emitted: false,
2969                shape_changed: true,
2970            },
2971            UnixNanos::from(7),
2972        )
2973        .unwrap()
2974        .expect("Open with shape_changed emits Updated");
2975
2976        match event {
2977            ParsedOrderEvent::Updated(e) => {
2978                assert_eq!(e.client_order_id, test_cloid());
2979                assert_eq!(e.quantity, Quantity::from("0.0050"));
2980                assert_eq!(e.price, Some(Price::from("2352.74")));
2981            }
2982            other => panic!("expected Updated, was {other:?}"),
2983        }
2984    }
2985
2986    #[rstest]
2987    fn parse_lighter_order_event_repeat_open_after_accept_is_silent() {
2988        // Without a shape change, a repeat `Open` for an already-accepted
2989        // tracked order must return `None` so partial-fill snapshots and
2990        // reconnect replays do not flood the engine with phantom updates.
2991        let instrument = create_test_instrument();
2992        let order = stub_order(LighterOrderStatus::Open);
2993
2994        let event = parse_lighter_order_event(
2995            &order,
2996            &instrument,
2997            &test_identity(),
2998            test_cloid(),
2999            account_id(),
3000            test_trader_id(),
3001            OpenFrameContext {
3002                accepted_already_emitted: true,
3003                triggered_already_emitted: false,
3004                shape_changed: false,
3005            },
3006            UnixNanos::from(7),
3007        )
3008        .unwrap();
3009
3010        assert!(event.is_none());
3011    }
3012
3013    #[rstest]
3014    fn parse_lighter_order_event_triggered_dedup_via_open_ctx() {
3015        // A subsequent `Open` frame after `Triggered` already fired must
3016        // not re-emit `Triggered`. The dispatcher tracks this via the
3017        // `triggered_already_emitted` flag.
3018        let instrument = create_test_instrument();
3019        let mut order = stub_order(LighterOrderStatus::Open);
3020        order.trigger_status = LighterTriggerStatus::Ready;
3021
3022        let event = parse_lighter_order_event(
3023            &order,
3024            &instrument,
3025            &test_identity(),
3026            test_cloid(),
3027            account_id(),
3028            test_trader_id(),
3029            OpenFrameContext {
3030                accepted_already_emitted: true,
3031                triggered_already_emitted: true,
3032                shape_changed: false,
3033            },
3034            UnixNanos::from(7),
3035        )
3036        .unwrap();
3037
3038        // After triggered_already_emitted and accepted_already_emitted,
3039        // with no shape change, the frame is silent.
3040        assert!(event.is_none());
3041    }
3042
3043    #[rstest]
3044    fn parse_lighter_order_event_emits_triggered_after_accept() {
3045        // A conditional order's trigger can fire AFTER the initial accept
3046        // landed. The Open frame's `trigger_status = Ready` must produce
3047        // `Triggered` regardless of `accepted_already_emitted`.
3048        let instrument = create_test_instrument();
3049        let mut order = stub_order(LighterOrderStatus::Open);
3050        order.order_type = LighterOrderKind::StopLossLimit;
3051        order.trigger_price = Decimal::from(2300);
3052        order.trigger_status = LighterTriggerStatus::Ready;
3053
3054        let event = parse_lighter_order_event(
3055            &order,
3056            &instrument,
3057            &test_identity_for(OrderType::StopLimit),
3058            test_cloid(),
3059            account_id(),
3060            test_trader_id(),
3061            OpenFrameContext {
3062                accepted_already_emitted: true,
3063                triggered_already_emitted: false,
3064                shape_changed: false,
3065            },
3066            UnixNanos::from(7),
3067        )
3068        .unwrap()
3069        .expect("trigger Ready after accept emits Triggered");
3070
3071        match event {
3072            ParsedOrderEvent::Triggered(_) => {}
3073            other => panic!("expected Triggered, was {other:?}"),
3074        }
3075    }
3076
3077    #[rstest]
3078    fn parse_lighter_order_event_emits_triggered_when_trigger_ready_fresh() {
3079        let instrument = create_test_instrument();
3080        let mut order = stub_order(LighterOrderStatus::Open);
3081        order.filled_base_amount = Decimal::ZERO;
3082        order.order_type = LighterOrderKind::StopLossLimit;
3083        order.trigger_price = Decimal::from(2300);
3084        order.trigger_status = LighterTriggerStatus::Ready;
3085
3086        let event_fresh = parse_lighter_order_event(
3087            &order,
3088            &instrument,
3089            &test_identity_for(OrderType::StopLimit),
3090            test_cloid(),
3091            account_id(),
3092            test_trader_id(),
3093            OpenFrameContext {
3094                accepted_already_emitted: false,
3095                triggered_already_emitted: false,
3096                shape_changed: false,
3097            },
3098            UnixNanos::from(7),
3099        )
3100        .unwrap()
3101        .expect("trigger_status=Ready on fresh open emits Triggered");
3102
3103        match event_fresh {
3104            ParsedOrderEvent::Triggered(_) => {}
3105            other => panic!("expected Triggered for fresh ready trigger, was {other:?}"),
3106        }
3107    }
3108
3109    #[rstest]
3110    fn lighter_order_shape_round_trips_values() {
3111        let instrument = create_test_instrument();
3112        let order = stub_order(LighterOrderStatus::Open);
3113
3114        let shape = lighter_order_shape(&order, &instrument, OrderType::Limit).unwrap();
3115
3116        assert_eq!(shape.quantity, Quantity::from("0.0050"));
3117        assert_eq!(shape.price, Some(Price::from("2352.74")));
3118        assert_eq!(shape.trigger_price, None);
3119    }
3120
3121    #[rstest]
3122    fn lighter_order_shape_distinguishes_modified_payload() {
3123        let instrument = create_test_instrument();
3124        let original = stub_order(LighterOrderStatus::Open);
3125        let mut modified = original.clone();
3126        modified.price = Decimal::from_str("2400.00").unwrap();
3127
3128        let shape_original = lighter_order_shape(&original, &instrument, OrderType::Limit).unwrap();
3129        let shape_modified = lighter_order_shape(&modified, &instrument, OrderType::Limit).unwrap();
3130
3131        assert_ne!(shape_original, shape_modified);
3132    }
3133
3134    #[rstest]
3135    #[case::limit(OrderType::Limit, Some(Price::from("2352.74")), None)]
3136    #[case::market(OrderType::Market, None, None)]
3137    #[case::stop_market(OrderType::StopMarket, None, Some(Price::from("2300.00")))]
3138    #[case::stop_limit(
3139        OrderType::StopLimit,
3140        Some(Price::from("2352.74")),
3141        Some(Price::from("2300.00"))
3142    )]
3143    fn lighter_order_shape_projects_fields_for_order_type(
3144        #[case] order_type: OrderType,
3145        #[case] expected_price: Option<Price>,
3146        #[case] expected_trigger: Option<Price>,
3147    ) {
3148        let instrument = create_test_instrument();
3149        let mut order = stub_order(LighterOrderStatus::Pending);
3150        order.trigger_price = Decimal::from_str("2300.00").unwrap();
3151
3152        let shape = lighter_order_shape(&order, &instrument, order_type).unwrap();
3153
3154        assert_eq!(shape.price, expected_price);
3155        assert_eq!(shape.trigger_price, expected_trigger);
3156    }
3157
3158    #[rstest]
3159    fn parse_lighter_order_event_emits_rejected_for_post_only_cancel() {
3160        let instrument = create_test_instrument();
3161        let order = stub_order(LighterOrderStatus::CanceledPostOnly);
3162
3163        let event = parse_lighter_order_event(
3164            &order,
3165            &instrument,
3166            &test_identity(),
3167            test_cloid(),
3168            account_id(),
3169            test_trader_id(),
3170            OpenFrameContext {
3171                accepted_already_emitted: false,
3172                triggered_already_emitted: false,
3173                shape_changed: false,
3174            },
3175            UnixNanos::from(7),
3176        )
3177        .unwrap()
3178        .expect("post-only cancel emits Rejected");
3179
3180        match event {
3181            ParsedOrderEvent::Rejected(e) => {
3182                assert!(e.due_post_only);
3183                assert_eq!(e.reason, "post-only");
3184            }
3185            other => panic!("expected Rejected, was {other:?}"),
3186        }
3187    }
3188
3189    #[rstest]
3190    fn parse_lighter_order_event_emits_expired_for_canceled_expired() {
3191        let instrument = create_test_instrument();
3192        let order = stub_order(LighterOrderStatus::CanceledExpired);
3193
3194        let event = parse_lighter_order_event(
3195            &order,
3196            &instrument,
3197            &test_identity(),
3198            test_cloid(),
3199            account_id(),
3200            test_trader_id(),
3201            OpenFrameContext {
3202                accepted_already_emitted: false,
3203                triggered_already_emitted: false,
3204                shape_changed: false,
3205            },
3206            UnixNanos::from(7),
3207        )
3208        .unwrap()
3209        .expect("canceled-expired emits Expired");
3210
3211        match event {
3212            ParsedOrderEvent::Expired(_) => {}
3213            other => panic!("expected Expired, was {other:?}"),
3214        }
3215    }
3216
3217    #[rstest]
3218    #[case::canceled(LighterOrderStatus::Canceled, None)]
3219    #[case::reduce_only(LighterOrderStatus::CanceledReduceOnly, Some("reduce-only"))]
3220    #[case::self_trade(LighterOrderStatus::CanceledSelfTrade, Some("self-trade"))]
3221    #[case::liquidation(LighterOrderStatus::CanceledLiquidation, Some("liquidation"))]
3222    fn parse_lighter_order_event_emits_canceled_for_other_cancel_variants(
3223        #[case] status: LighterOrderStatus,
3224        #[case] expected_reason: Option<&str>,
3225    ) {
3226        let instrument = create_test_instrument();
3227        let order = stub_order(status);
3228
3229        let event = parse_lighter_order_event(
3230            &order,
3231            &instrument,
3232            &test_identity(),
3233            test_cloid(),
3234            account_id(),
3235            test_trader_id(),
3236            OpenFrameContext {
3237                accepted_already_emitted: false,
3238                triggered_already_emitted: false,
3239                shape_changed: false,
3240            },
3241            UnixNanos::from(7),
3242        )
3243        .unwrap()
3244        .expect("cancel variant emits Canceled");
3245
3246        match event {
3247            ParsedOrderEvent::Canceled(canceled) => {
3248                assert_eq!(
3249                    canceled.reason.map(|reason| reason.as_str()),
3250                    expected_reason
3251                );
3252            }
3253            other => panic!("expected Canceled, was {other:?}"),
3254        }
3255    }
3256
3257    #[rstest]
3258    #[case::in_progress(LighterOrderStatus::InProgress)]
3259    #[case::filled(LighterOrderStatus::Filled)]
3260    fn parse_lighter_order_event_returns_none_for_silent_statuses(
3261        #[case] status: LighterOrderStatus,
3262    ) {
3263        // InProgress carries no actionable event; Filled flows through the
3264        // trade stream and produces `OrderFilled` from `parse_lighter_order_filled`.
3265        let instrument = create_test_instrument();
3266        let order = stub_order(status);
3267
3268        let event = parse_lighter_order_event(
3269            &order,
3270            &instrument,
3271            &test_identity(),
3272            test_cloid(),
3273            account_id(),
3274            test_trader_id(),
3275            OpenFrameContext {
3276                accepted_already_emitted: false,
3277                triggered_already_emitted: false,
3278                shape_changed: false,
3279            },
3280            UnixNanos::from(7),
3281        )
3282        .unwrap();
3283
3284        assert!(event.is_none(), "expected None for {status:?}");
3285    }
3286
3287    #[rstest]
3288    #[case::bidder_maker_ask_is_taker(true, true, "0.06160765 USDC")]
3289    #[case::asker_maker_ask_is_maker(true, false, "0.00880109 USDC")]
3290    #[case::bidder_maker_bid_is_maker(false, true, "0.00880109 USDC")]
3291    #[case::asker_maker_bid_is_taker(false, false, "0.06160765 USDC")]
3292    fn parse_lighter_order_filled_builds_order_filled_for_account(
3293        #[case] is_maker_ask: bool,
3294        #[case] user_is_bidder: bool,
3295        #[case] expected_commission: &str,
3296    ) {
3297        let instrument = create_test_instrument();
3298        let trade = stub_account_trade(1234, is_maker_ask, user_is_bidder);
3299
3300        let filled = parse_lighter_order_filled(
3301            &trade,
3302            &instrument,
3303            &test_identity(),
3304            test_cloid(),
3305            account_id(),
3306            test_trader_id(),
3307            1234,
3308            UnixNanos::from(7),
3309        )
3310        .unwrap()
3311        .expect("trade involving account emits OrderFilled");
3312
3313        assert_eq!(filled.client_order_id, test_cloid());
3314        assert_eq!(filled.order_side, OrderSide::Sell); // identity wins
3315        assert_eq!(filled.order_type, OrderType::Limit);
3316        assert_eq!(filled.last_qty, Quantity::from("0.1336"));
3317        assert_eq!(filled.last_px, Price::from("2352.73"));
3318        assert_eq!(filled.commission, Some(Money::from(expected_commission)));
3319    }
3320
3321    #[rstest]
3322    fn parse_lighter_order_filled_returns_none_for_other_account() {
3323        let instrument = create_test_instrument();
3324        // account_index does not appear on either side of the trade.
3325        let trade = stub_account_trade(1234, true, true);
3326
3327        let filled = parse_lighter_order_filled(
3328            &trade,
3329            &instrument,
3330            &test_identity(),
3331            test_cloid(),
3332            account_id(),
3333            test_trader_id(),
3334            99_999, // mismatched account_index
3335            UnixNanos::from(7),
3336        )
3337        .unwrap();
3338
3339        assert!(filled.is_none());
3340    }
3341}