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