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