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