Skip to main content

nautilus_lighter/websocket/
parse.rs

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