Skip to main content

nautilus_lighter/websocket/
messages.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//! Wire frames and handler-output message types for Lighter streams.
17
18use std::fmt::Debug;
19
20use ahash::AHashMap;
21use nautilus_core::serialization::{
22    deserialize_decimal, deserialize_decimal_from_str, deserialize_optional_decimal,
23};
24use nautilus_model::{
25    data::{
26        Bar, FundingRateUpdate, IndexPriceUpdate, MarkPriceUpdate, OrderBookDeltas,
27        OrderBookDepth10, QuoteTick, TradeTick,
28    },
29    events::AccountState,
30    reports::PositionStatusReport,
31};
32use rust_decimal::Decimal;
33use serde::{
34    Deserialize, Serialize,
35    de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor},
36};
37use serde_json::value::RawValue;
38use ustr::Ustr;
39
40use crate::{
41    common::enums::LighterCandleResolution,
42    http::models::{LighterOrder, LighterPriceLevel, LighterTrade},
43};
44
45/// Inbound message produced by the Lighter feed handler and consumed by the
46/// data and execution clients.
47///
48/// Account-stream variants carry typed Nautilus reports so that the execution
49/// client can route them without re-parsing. Fills can arrive on both
50/// `account_orders` (as the embedded fill quantity) and `account_all_trades`
51/// (as discrete trade prints); the handler emits both untouched and the
52/// execution-side consumer is responsible for cross-source dedup.
53#[derive(Debug, Clone)]
54pub enum NautilusWsMessage {
55    Trades(Vec<TradeTick>),
56    Quote(QuoteTick),
57    Deltas(OrderBookDeltas),
58    Depth10(Box<OrderBookDepth10>),
59    Bar(Bar),
60    MarkPrice(MarkPriceUpdate),
61    IndexPrice(IndexPriceUpdate),
62    FundingRate(FundingRateUpdate),
63    ExecutionReports(Vec<ExecutionReport>),
64    PositionSnapshot {
65        reports: Vec<PositionStatusReport>,
66        skipped_market_ids: Vec<i16>,
67    },
68    AccountState(Box<AccountState>),
69    SendTxAck {
70        tx_hash: Option<String>,
71        code: i64,
72    },
73    SendTxRejected {
74        source: SendTxRejectionSource,
75        code: Option<i64>,
76        message: String,
77        tx_hash: Option<String>,
78    },
79    Raw(serde_json::Value),
80    Reconnected,
81    /// Marker emitted by the feed handler right after each account stream
82    /// has delivered its first frame. The execution consumption loop forwards
83    /// any preceding typed reports first, then marks the corresponding
84    /// readiness flag, keeping `connect()` blocked until applied state is
85    /// observable to strategies.
86    AccountStreamFirstFrame(AccountStream),
87}
88
89/// Identifier for one of the five account-scoped WebSocket streams the
90/// execution client subscribes to on connect.
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
92pub enum AccountStream {
93    Orders,
94    Trades,
95    Positions,
96    Assets,
97    UserStats,
98}
99
100/// Origin of a Lighter `sendTx` rejection signal.
101///
102/// `Ack` is a direct non-200 response to our own `jsonapi/sendtx` request,
103/// attributable via the echoed `tx_hash` when present and the FIFO head
104/// otherwise. `BareError` is a standalone error frame that carries no
105/// correlation field, so attribution relies on the FIFO pending queue plus a
106/// short attribution window; only codes in the venue's transaction range are
107/// routed here at all.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub enum SendTxRejectionSource {
110    Ack,
111    BareError,
112}
113
114/// Wrapper for the raw venue payloads emitted on Lighter account streams.
115///
116/// Carries unparsed [`LighterOrder`] / [`LighterTrade`] so the execution
117/// consumption loop can decide between two paths:
118///
119/// - Tracked: build a typed `OrderEventAny` variant via the parsers in
120///   [`crate::websocket::parse`].
121/// - Untracked: convert to `OrderStatusReport` / `FillReport` and forward
122///   for the engine's external-order reconciliation pipeline.
123///
124/// The handler produces these in batches per frame so that all reports
125/// observed in one venue update are delivered atomically to the consumer.
126#[derive(Debug, Clone)]
127#[allow(
128    clippy::large_enum_variant,
129    reason = "payload variants are short-lived and consumed once on the venue-message channel"
130)]
131pub enum ExecutionReport {
132    Order(LighterOrder),
133    Fill(LighterTrade),
134}
135
136#[derive(Clone, Serialize, Deserialize)]
137#[serde(tag = "type")]
138pub enum LighterWsRequest {
139    #[serde(rename = "subscribe")]
140    Subscribe {
141        channel: String,
142        #[serde(skip_serializing_if = "Option::is_none")]
143        auth: Option<String>,
144    },
145    #[serde(rename = "unsubscribe")]
146    Unsubscribe { channel: String },
147    #[serde(rename = "jsonapi/sendtx")]
148    SendTx { data: LighterWsSendTx },
149}
150
151impl Debug for LighterWsRequest {
152    /// Custom `Debug` that redacts the `auth` field of `Subscribe`. The
153    /// serialized form of this enum is what hits the wire as a Lighter L2
154    /// bearer token; deriving `Debug` would otherwise leak it via any
155    /// `format!("{request:?}")` call in error or trace paths.
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            Self::Subscribe { channel, auth } => f
159                .debug_struct(stringify!(Subscribe))
160                .field("channel", channel)
161                .field("authed", &auth.is_some())
162                .finish(),
163            Self::Unsubscribe { channel } => f
164                .debug_struct(stringify!(Unsubscribe))
165                .field("channel", channel)
166                .finish(),
167            Self::SendTx { data } => f
168                .debug_struct(stringify!(SendTx))
169                .field("data", data)
170                .finish(),
171        }
172    }
173}
174
175impl LighterWsRequest {
176    #[must_use]
177    pub fn subscribe(channel: impl Into<String>) -> Self {
178        Self::Subscribe {
179            channel: channel.into(),
180            auth: None,
181        }
182    }
183
184    #[must_use]
185    pub fn subscribe_auth(channel: impl Into<String>, auth: impl Into<String>) -> Self {
186        Self::Subscribe {
187            channel: channel.into(),
188            auth: Some(auth.into()),
189        }
190    }
191
192    #[must_use]
193    pub fn unsubscribe(channel: impl Into<String>) -> Self {
194        Self::Unsubscribe {
195            channel: channel.into(),
196        }
197    }
198}
199
200/// `tx_info` is carried as [`Box<RawValue>`] so the typed-tx renderer in
201/// [`crate::signing::tx::TxInfoJson`] can hand the wrapper a pre-rendered
202/// JSON string without paying for a parse-into-Value round-trip on every
203/// exec command. The outer [`LighterWsRequest`] serialization emits the raw
204/// source bytes inline. `PartialEq` is not derived because [`RawValue`]
205/// doesn't implement it.
206#[derive(Debug, Clone, Serialize, Deserialize)]
207pub struct LighterWsSendTx {
208    pub tx_type: u8,
209    pub tx_info: Box<RawValue>,
210}
211
212/// Wire labels for the Lighter WebSocket channel taxonomy.
213///
214/// Centralizes the channel name strings (`"order_book"`, `"trade"`, ...) so
215/// outbound subscription payloads, topic keys, and inbound topic parsing all
216/// share one source of truth.
217#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
218pub enum LighterWsChannelKind {
219    OrderBook,
220    Ticker,
221    Trade,
222    Candle,
223    MarketStats,
224    SpotMarketStats,
225    AccountAll,
226    AccountOrders,
227    AccountAllOrders,
228    AccountAllTrades,
229    AccountAllPositions,
230    AccountAllAssets,
231    UserStats,
232    Height,
233}
234
235impl LighterWsChannelKind {
236    /// Returns the venue wire label for this channel kind.
237    #[must_use]
238    pub const fn as_wire_str(self) -> &'static str {
239        match self {
240            Self::OrderBook => "order_book",
241            Self::Ticker => "ticker",
242            Self::Trade => "trade",
243            Self::Candle => "candle",
244            Self::MarketStats => "market_stats",
245            Self::SpotMarketStats => "spot_market_stats",
246            Self::AccountAll => "account_all",
247            Self::AccountOrders => "account_orders",
248            Self::AccountAllOrders => "account_all_orders",
249            Self::AccountAllTrades => "account_all_trades",
250            Self::AccountAllPositions => "account_all_positions",
251            Self::AccountAllAssets => "account_all_assets",
252            Self::UserStats => "user_stats",
253            Self::Height => "height",
254        }
255    }
256
257    /// Returns the channel kind matching `wire_str`, or `None` if unknown.
258    #[must_use]
259    pub fn from_wire_str(wire_str: &str) -> Option<Self> {
260        match wire_str {
261            "order_book" => Some(Self::OrderBook),
262            "ticker" => Some(Self::Ticker),
263            "trade" => Some(Self::Trade),
264            "candle" => Some(Self::Candle),
265            "market_stats" => Some(Self::MarketStats),
266            "spot_market_stats" => Some(Self::SpotMarketStats),
267            "account_all" => Some(Self::AccountAll),
268            "account_orders" => Some(Self::AccountOrders),
269            "account_all_orders" => Some(Self::AccountAllOrders),
270            "account_all_trades" => Some(Self::AccountAllTrades),
271            "account_all_positions" => Some(Self::AccountAllPositions),
272            "account_all_assets" => Some(Self::AccountAllAssets),
273            "user_stats" => Some(Self::UserStats),
274            "height" => Some(Self::Height),
275            _ => None,
276        }
277    }
278}
279
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum LighterWsChannel {
282    OrderBook(i16),
283    Ticker(i16),
284    MarketStats(LighterMarketSelection),
285    SpotMarketStats(LighterMarketSelection),
286    Trade(i16),
287    Candle {
288        market_index: i16,
289        resolution: LighterCandleResolution,
290    },
291    AccountAll(i64),
292    AccountOrders {
293        market_index: i16,
294        account_index: i64,
295    },
296    AccountAllOrders(i64),
297    AccountAllTrades(i64),
298    AccountAllPositions(i64),
299    AccountAllAssets(i64),
300    UserStats(i64),
301    Height,
302}
303
304impl LighterWsChannel {
305    /// Returns the kind of this channel.
306    #[must_use]
307    pub const fn kind(&self) -> LighterWsChannelKind {
308        match self {
309            Self::OrderBook(_) => LighterWsChannelKind::OrderBook,
310            Self::Ticker(_) => LighterWsChannelKind::Ticker,
311            Self::Trade(_) => LighterWsChannelKind::Trade,
312            Self::Candle { .. } => LighterWsChannelKind::Candle,
313            Self::MarketStats(_) => LighterWsChannelKind::MarketStats,
314            Self::SpotMarketStats(_) => LighterWsChannelKind::SpotMarketStats,
315            Self::AccountAll(_) => LighterWsChannelKind::AccountAll,
316            Self::AccountOrders { .. } => LighterWsChannelKind::AccountOrders,
317            Self::AccountAllOrders(_) => LighterWsChannelKind::AccountAllOrders,
318            Self::AccountAllTrades(_) => LighterWsChannelKind::AccountAllTrades,
319            Self::AccountAllPositions(_) => LighterWsChannelKind::AccountAllPositions,
320            Self::AccountAllAssets(_) => LighterWsChannelKind::AccountAllAssets,
321            Self::UserStats(_) => LighterWsChannelKind::UserStats,
322            Self::Height => LighterWsChannelKind::Height,
323        }
324    }
325
326    #[must_use]
327    pub fn subscription_channel(&self) -> String {
328        let kind = self.kind().as_wire_str();
329
330        match self {
331            Self::OrderBook(market_index)
332            | Self::Ticker(market_index)
333            | Self::Trade(market_index) => format!("{kind}/{market_index}"),
334            Self::Candle {
335                market_index,
336                resolution,
337            } => format!("{kind}/{market_index}/{}", resolution.as_str()),
338            Self::MarketStats(selection) | Self::SpotMarketStats(selection) => {
339                format!("{kind}/{}", selection.subscription_value())
340            }
341            Self::AccountAll(account_index)
342            | Self::AccountAllOrders(account_index)
343            | Self::AccountAllTrades(account_index)
344            | Self::AccountAllPositions(account_index)
345            | Self::AccountAllAssets(account_index)
346            | Self::UserStats(account_index) => format!("{kind}/{account_index}"),
347            Self::AccountOrders {
348                market_index,
349                account_index,
350            } => format!("{kind}/{market_index}/{account_index}"),
351            Self::Height => kind.to_string(),
352        }
353    }
354
355    /// Returns the canonical topic key used to track this subscription.
356    ///
357    /// Lighter inbound frames carry a `channel` field formatted with `:`
358    /// (e.g. `order_book:0`) while outbound subscribe payloads use `/`
359    /// (e.g. `order_book/0`). The topic key matches the inbound form so the
360    /// handler can correlate frame channel fields against the tracked
361    /// subscription set.
362    #[must_use]
363    pub fn topic_key(&self) -> String {
364        self.subscription_channel().replace('/', ":")
365    }
366
367    /// Returns `true` when subscribing to this channel requires an auth token.
368    #[must_use]
369    pub const fn requires_auth(&self) -> bool {
370        matches!(
371            self,
372            Self::AccountAll(_)
373                | Self::AccountOrders { .. }
374                | Self::AccountAllOrders(_)
375                | Self::AccountAllTrades(_)
376                | Self::AccountAllPositions(_)
377                | Self::AccountAllAssets(_)
378                | Self::UserStats(_)
379        )
380    }
381}
382
383#[derive(Debug, Copy, Clone, PartialEq, Eq)]
384pub enum LighterMarketSelection {
385    All,
386    Market(i16),
387}
388
389impl LighterMarketSelection {
390    fn subscription_value(self) -> String {
391        match self {
392            Self::All => "all".to_string(),
393            Self::Market(market_index) => market_index.to_string(),
394        }
395    }
396}
397
398#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
399#[serde(tag = "type")]
400pub enum LighterWsFrame {
401    #[serde(rename = "subscribed/order_book")]
402    OrderBookSnapshot {
403        channel: Ustr,
404        #[serde(default)]
405        last_updated_at: u64,
406        offset: i64,
407        order_book: LighterWsOrderBook,
408        timestamp: u64,
409    },
410    #[serde(rename = "update/order_book")]
411    OrderBook {
412        channel: Ustr,
413        last_updated_at: u64,
414        offset: i64,
415        order_book: LighterWsOrderBook,
416        timestamp: u64,
417    },
418    #[serde(rename = "subscribed/ticker")]
419    TickerSnapshot {
420        channel: Ustr,
421        #[serde(default)]
422        last_updated_at: u64,
423        nonce: i64,
424        ticker: LighterTicker,
425        timestamp: u64,
426    },
427    #[serde(rename = "update/ticker")]
428    Ticker {
429        channel: Ustr,
430        last_updated_at: u64,
431        nonce: i64,
432        ticker: LighterTicker,
433        timestamp: u64,
434    },
435    #[serde(rename = "update/market_stats", alias = "subscribed/market_stats")]
436    MarketStats {
437        channel: Ustr,
438        market_stats: LighterMarketStatsPayload,
439        timestamp: u64,
440    },
441    #[serde(
442        rename = "update/spot_market_stats",
443        alias = "subscribed/spot_market_stats"
444    )]
445    SpotMarketStats {
446        channel: Ustr,
447        spot_market_stats: LighterSpotMarketStatsPayload,
448        timestamp: u64,
449    },
450    #[serde(rename = "subscribed/trade")]
451    TradeSnapshot {
452        channel: Ustr,
453        #[serde(default, deserialize_with = "deserialize_trade_vec")]
454        liquidation_trades: Vec<LighterTrade>,
455        nonce: i64,
456        #[serde(default, deserialize_with = "deserialize_trade_vec")]
457        trades: Vec<LighterTrade>,
458    },
459    #[serde(rename = "update/trade")]
460    Trade {
461        channel: Ustr,
462        #[serde(default, deserialize_with = "deserialize_trade_vec")]
463        liquidation_trades: Vec<LighterTrade>,
464        nonce: i64,
465        #[serde(default, deserialize_with = "deserialize_trade_vec")]
466        trades: Vec<LighterTrade>,
467    },
468    #[serde(rename = "update/account_orders")]
469    AccountOrders {
470        account: i64,
471        channel: Ustr,
472        nonce: i64,
473        orders: AHashMap<Ustr, Vec<LighterOrder>>,
474    },
475    #[serde(
476        rename = "update/account_all_orders",
477        alias = "subscribed/account_all_orders"
478    )]
479    AccountAllOrders {
480        channel: Ustr,
481        orders: AHashMap<Ustr, Vec<LighterOrder>>,
482    },
483    #[serde(rename = "subscribed/account_all_trades")]
484    AccountAllTradesSnapshot {
485        channel: Ustr,
486        #[serde(default, deserialize_with = "deserialize_trade_vec")]
487        trades: Vec<LighterTrade>,
488        #[serde(deserialize_with = "deserialize_decimal")]
489        total_volume: Decimal,
490        #[serde(deserialize_with = "deserialize_decimal")]
491        monthly_volume: Decimal,
492        #[serde(deserialize_with = "deserialize_decimal")]
493        weekly_volume: Decimal,
494        #[serde(deserialize_with = "deserialize_decimal")]
495        daily_volume: Decimal,
496    },
497    #[serde(rename = "update/account_all_trades")]
498    AccountAllTrades {
499        channel: Ustr,
500        trades: AHashMap<Ustr, Vec<LighterTrade>>,
501    },
502    #[serde(
503        rename = "update/account_all_positions",
504        alias = "subscribed/account_all_positions"
505    )]
506    AccountAllPositions {
507        channel: Ustr,
508        positions: AHashMap<Ustr, LighterPosition>,
509        #[serde(default)]
510        shares: Vec<LighterPoolShares>,
511        last_funding_round: Option<AHashMap<Ustr, Decimal>>,
512        last_funding_discount: Option<AHashMap<Ustr, Decimal>>,
513    },
514    #[serde(
515        rename = "update/account_all_assets",
516        alias = "subscribed/account_all_assets"
517    )]
518    AccountAllAssets {
519        assets: AHashMap<Ustr, LighterAsset>,
520        channel: Ustr,
521        timestamp: u64,
522    },
523    #[serde(rename = "update/user_stats", alias = "subscribed/user_stats")]
524    UserStats {
525        channel: Ustr,
526        stats: LighterUserStats,
527        timestamp: u64,
528    },
529    #[serde(rename = "update/height")]
530    Height {
531        channel: Ustr,
532        height: i64,
533        timestamp: u64,
534    },
535    #[serde(rename = "subscribed/candle")]
536    CandleSnapshot {
537        channel: Ustr,
538        candles: Vec<LighterWsCandle>,
539        timestamp: u64,
540    },
541    #[serde(rename = "update/candle")]
542    Candle {
543        channel: Ustr,
544        candles: Vec<LighterWsCandle>,
545        timestamp: u64,
546    },
547}
548
549#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
550pub struct LighterWsCandle {
551    pub t: i64,
552    #[serde(deserialize_with = "deserialize_decimal")]
553    pub o: Decimal,
554    #[serde(deserialize_with = "deserialize_decimal")]
555    pub h: Decimal,
556    #[serde(deserialize_with = "deserialize_decimal")]
557    pub l: Decimal,
558    #[serde(deserialize_with = "deserialize_decimal")]
559    pub c: Decimal,
560    #[serde(deserialize_with = "deserialize_decimal")]
561    pub v: Decimal,
562    #[serde(default, rename = "V", deserialize_with = "deserialize_decimal")]
563    pub quote_volume: Decimal,
564    #[serde(default)]
565    pub i: i64,
566}
567
568#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
569pub struct LighterWsOrderBook {
570    pub code: i32,
571    pub asks: Vec<LighterPriceLevel>,
572    pub bids: Vec<LighterPriceLevel>,
573    pub offset: i64,
574    pub nonce: i64,
575    pub last_updated_at: u64,
576    pub begin_nonce: i64,
577}
578
579#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
580pub struct LighterTicker {
581    pub s: Ustr,
582    pub a: LighterPriceLevel,
583    pub b: LighterPriceLevel,
584    pub last_updated_at: u64,
585}
586
587#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
588#[serde(untagged)]
589pub enum LighterMarketStatsPayload {
590    All(AHashMap<Ustr, LighterMarketStats>),
591    One(Box<LighterMarketStats>),
592}
593
594#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
595pub struct LighterMarketStats {
596    pub symbol: Ustr,
597    pub market_id: i16,
598    #[serde(deserialize_with = "deserialize_decimal_from_str")]
599    pub index_price: Decimal,
600    #[serde(deserialize_with = "deserialize_decimal_from_str")]
601    pub mark_price: Decimal,
602    #[serde(deserialize_with = "deserialize_decimal_from_str")]
603    pub mid_price: Decimal,
604    #[serde(deserialize_with = "deserialize_decimal_from_str")]
605    pub open_interest: Decimal,
606    #[serde(deserialize_with = "deserialize_decimal_from_str")]
607    pub open_interest_limit: Decimal,
608    #[serde(deserialize_with = "deserialize_decimal_from_str")]
609    pub funding_clamp_small: Decimal,
610    #[serde(deserialize_with = "deserialize_decimal_from_str")]
611    pub funding_clamp_big: Decimal,
612    #[serde(deserialize_with = "deserialize_decimal_from_str")]
613    pub last_trade_price: Decimal,
614    #[serde(deserialize_with = "deserialize_decimal_from_str")]
615    pub current_funding_rate: Decimal,
616    #[serde(deserialize_with = "deserialize_decimal_from_str")]
617    pub funding_rate: Decimal,
618    pub funding_timestamp: u64,
619    #[serde(deserialize_with = "deserialize_decimal")]
620    pub daily_base_token_volume: Decimal,
621    #[serde(deserialize_with = "deserialize_decimal")]
622    pub daily_quote_token_volume: Decimal,
623    #[serde(deserialize_with = "deserialize_decimal")]
624    pub daily_price_low: Decimal,
625    #[serde(deserialize_with = "deserialize_decimal")]
626    pub daily_price_high: Decimal,
627    #[serde(deserialize_with = "deserialize_decimal")]
628    pub daily_price_change: Decimal,
629}
630
631#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
632#[serde(untagged)]
633pub enum LighterSpotMarketStatsPayload {
634    All(AHashMap<Ustr, LighterSpotMarketStats>),
635    One(Box<LighterSpotMarketStats>),
636}
637
638#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
639pub struct LighterSpotMarketStats {
640    pub symbol: Ustr,
641    pub market_id: i16,
642    #[serde(deserialize_with = "deserialize_decimal_from_str")]
643    pub index_price: Decimal,
644    #[serde(deserialize_with = "deserialize_decimal_from_str")]
645    pub mid_price: Decimal,
646    #[serde(deserialize_with = "deserialize_decimal_from_str")]
647    pub last_trade_price: Decimal,
648    #[serde(deserialize_with = "deserialize_decimal")]
649    pub daily_base_token_volume: Decimal,
650    #[serde(deserialize_with = "deserialize_decimal")]
651    pub daily_quote_token_volume: Decimal,
652    #[serde(deserialize_with = "deserialize_decimal")]
653    pub daily_price_low: Decimal,
654    #[serde(deserialize_with = "deserialize_decimal")]
655    pub daily_price_high: Decimal,
656    #[serde(deserialize_with = "deserialize_decimal")]
657    pub daily_price_change: Decimal,
658}
659
660#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
661pub struct LighterPosition {
662    pub market_id: i16,
663    pub symbol: Ustr,
664    #[serde(deserialize_with = "deserialize_decimal_from_str")]
665    pub initial_margin_fraction: Decimal,
666    pub open_order_count: i64,
667    pub pending_order_count: i64,
668    pub position_tied_order_count: i64,
669    pub sign: i8,
670    #[serde(deserialize_with = "deserialize_decimal_from_str")]
671    pub position: Decimal,
672    #[serde(deserialize_with = "deserialize_decimal_from_str")]
673    pub avg_entry_price: Decimal,
674    #[serde(deserialize_with = "deserialize_decimal_from_str")]
675    pub position_value: Decimal,
676    #[serde(deserialize_with = "deserialize_decimal_from_str")]
677    pub unrealized_pnl: Decimal,
678    #[serde(deserialize_with = "deserialize_decimal_from_str")]
679    pub realized_pnl: Decimal,
680    #[serde(deserialize_with = "deserialize_decimal_from_str")]
681    pub liquidation_price: Decimal,
682    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
683    pub total_funding_paid_out: Option<Decimal>,
684    pub margin_mode: i32,
685    #[serde(deserialize_with = "deserialize_decimal_from_str")]
686    pub allocated_margin: Decimal,
687    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
688    pub total_discount: Option<Decimal>,
689}
690
691#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
692pub struct LighterPoolShares {
693    pub public_pool_index: i64,
694    pub shares_amount: i64,
695    #[serde(deserialize_with = "deserialize_decimal_from_str")]
696    pub entry_usdc: Decimal,
697    #[serde(deserialize_with = "deserialize_decimal_from_str")]
698    pub principal_amount: Decimal,
699    pub entry_timestamp: u64,
700}
701
702/// Inner shape of the `user_stats.stats.cross_stats` and `.total_stats`
703/// substructs. Every field is a stringified decimal on the wire and
704/// denominated in USDC.
705#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
706pub struct LighterUserStatsScoped {
707    #[serde(deserialize_with = "deserialize_decimal_from_str")]
708    pub available_balance: Decimal,
709    #[serde(deserialize_with = "deserialize_decimal_from_str")]
710    pub buying_power: Decimal,
711    #[serde(deserialize_with = "deserialize_decimal_from_str")]
712    pub collateral: Decimal,
713    #[serde(deserialize_with = "deserialize_decimal_from_str")]
714    pub leverage: Decimal,
715    #[serde(deserialize_with = "deserialize_decimal_from_str")]
716    pub margin_usage: Decimal,
717    #[serde(deserialize_with = "deserialize_decimal_from_str")]
718    pub portfolio_value: Decimal,
719}
720
721/// Body of the `user_stats` frame. Top-level equity numbers mirror
722/// `total_stats`; `cross_stats` reports cross-margin equity only.
723#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
724pub struct LighterUserStats {
725    #[serde(default)]
726    pub account_trading_mode: i32,
727    #[serde(deserialize_with = "deserialize_decimal_from_str")]
728    pub available_balance: Decimal,
729    #[serde(deserialize_with = "deserialize_decimal_from_str")]
730    pub buying_power: Decimal,
731    #[serde(deserialize_with = "deserialize_decimal_from_str")]
732    pub collateral: Decimal,
733    #[serde(deserialize_with = "deserialize_decimal_from_str")]
734    pub leverage: Decimal,
735    #[serde(deserialize_with = "deserialize_decimal_from_str")]
736    pub margin_usage: Decimal,
737    #[serde(deserialize_with = "deserialize_decimal_from_str")]
738    pub portfolio_value: Decimal,
739    pub cross_stats: Option<LighterUserStatsScoped>,
740    pub total_stats: Option<LighterUserStatsScoped>,
741}
742
743#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
744pub struct LighterAsset {
745    pub symbol: Ustr,
746    pub asset_id: i16,
747    /// Spot-side balance for this asset (USDC sitting in the wallet bucket,
748    /// non-USDC spot holdings, etc).
749    #[serde(deserialize_with = "deserialize_decimal_from_str")]
750    pub balance: Decimal,
751    /// Spot-side amount reserved by resting spot orders.
752    #[serde(deserialize_with = "deserialize_decimal_from_str")]
753    pub locked_balance: Decimal,
754    /// Perp-side collateral for this asset. USDC on Lighter today; defaults
755    /// to zero when the wire omits the field (spot-only frames).
756    #[serde(default, deserialize_with = "deserialize_decimal_from_str")]
757    pub margin_balance: Decimal,
758    /// Per-asset margin treatment. Observed values: "disabled" (asset not
759    /// pledged as collateral). Defaults to empty when the wire omits it.
760    #[serde(default)]
761    pub margin_mode: Ustr,
762}
763
764fn deserialize_trade_vec<'de, D>(deserializer: D) -> Result<Vec<LighterTrade>, D::Error>
765where
766    D: serde::Deserializer<'de>,
767{
768    struct TradeVecVisitor;
769
770    impl<'de> Visitor<'de> for TradeVecVisitor {
771        type Value = Vec<LighterTrade>;
772
773        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
774            formatter.write_str("trade array, object keyed by market, or null")
775        }
776
777        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
778            Ok(Vec::new())
779        }
780
781        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
782            Ok(Vec::new())
783        }
784
785        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
786            let mut trades = Vec::with_capacity(seq.size_hint().unwrap_or(0));
787            while let Some(trade) = seq.next_element::<LighterTrade>()? {
788                trades.push(trade);
789            }
790            Ok(trades)
791        }
792
793        fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
794            let mut trades = Vec::new();
795            while let Some((_, mut market_trades)) =
796                map.next_entry::<IgnoredAny, Vec<LighterTrade>>()?
797            {
798                trades.append(&mut market_trades);
799            }
800            Ok(trades)
801        }
802    }
803
804    deserializer.deserialize_any(TradeVecVisitor)
805}
806
807#[cfg(test)]
808mod tests {
809    use std::str::FromStr;
810
811    use rstest::rstest;
812    use serde_json::Value;
813
814    use super::*;
815
816    const WS_ORDER_BOOK_UPDATE: &str = include_str!("../../test_data/ws_order_book_update.json");
817    const WS_ORDER_BOOK_SUBSCRIBED: &str =
818        include_str!("../../test_data/ws_order_book_subscribed.json");
819    const WS_ORDER_BOOK_SUBSCRIBED_EMPTY: &str =
820        include_str!("../../test_data/ws_order_book_subscribed_empty.json");
821    const WS_TRADE_UPDATE: &str = include_str!("../../test_data/ws_trade_update.json");
822    const WS_TRADE_SUBSCRIBED: &str = include_str!("../../test_data/ws_trade_subscribed.json");
823    const WS_TICKER_UPDATE: &str = include_str!("../../test_data/ws_ticker_update.json");
824    const WS_TICKER_SUBSCRIBED: &str = include_str!("../../test_data/ws_ticker_subscribed.json");
825    const WS_TICKER_SUBSCRIBED_EMPTY: &str =
826        include_str!("../../test_data/ws_ticker_subscribed_empty.json");
827    const WS_MARKET_STATS_UPDATE_SINGLE: &str =
828        include_str!("../../test_data/ws_market_stats_update_single.json");
829    const WS_MARKET_STATS_SUBSCRIBED_SINGLE: &str =
830        include_str!("../../test_data/ws_market_stats_subscribed_single.json");
831    const WS_MARKET_STATS_UPDATE_ALL: &str =
832        include_str!("../../test_data/ws_market_stats_update_all.json");
833    const WS_SPOT_MARKET_STATS_UPDATE_SINGLE: &str =
834        include_str!("../../test_data/ws_spot_market_stats_update_single.json");
835    const WS_SPOT_MARKET_STATS_SUBSCRIBED_SINGLE: &str =
836        include_str!("../../test_data/ws_spot_market_stats_subscribed_single.json");
837    const WS_SPOT_MARKET_STATS_UPDATE_ALL: &str =
838        include_str!("../../test_data/ws_spot_market_stats_update_all.json");
839    const WS_ACCOUNT_ALL_ASSETS_UPDATE: &str =
840        include_str!("../../test_data/ws_account_all_assets_update.json");
841    const WS_ACCOUNT_ORDERS_UPDATE: &str =
842        include_str!("../../test_data/ws_account_orders_update.json");
843    const WS_ACCOUNT_ALL_TRADES_UPDATE: &str =
844        include_str!("../../test_data/ws_account_all_trades_update.json");
845    const WS_ACCOUNT_ALL_POSITIONS_UPDATE: &str =
846        include_str!("../../test_data/ws_account_all_positions_update.json");
847    const WS_HEIGHT_UPDATE: &str = include_str!("../../test_data/ws_height_update.json");
848    const WS_CANDLE_SUBSCRIBED: &str = include_str!("../../test_data/ws_candle_subscribed.json");
849    const WS_CANDLE_UPDATE: &str = include_str!("../../test_data/ws_candle_update.json");
850
851    #[rstest]
852    fn test_subscription_request_serializes_public_channel() {
853        let channel = LighterWsChannel::OrderBook(0).subscription_channel();
854        let request = LighterWsRequest::subscribe(channel);
855
856        let json = serde_json::to_string(&request).unwrap();
857
858        assert_eq!(
859            serde_json::from_str::<Value>(&json).unwrap(),
860            serde_json::json!({
861                "type": "subscribe",
862                "channel": "order_book/0",
863            }),
864        );
865    }
866
867    #[rstest]
868    fn test_subscription_request_serializes_auth_channel() {
869        let channel = LighterWsChannel::AccountOrders {
870            market_index: 0,
871            account_index: 1234,
872        }
873        .subscription_channel();
874        let request = LighterWsRequest::subscribe_auth(channel, "token");
875
876        let json = serde_json::to_string(&request).unwrap();
877
878        assert_eq!(
879            serde_json::from_str::<Value>(&json).unwrap(),
880            serde_json::json!({
881                "type": "subscribe",
882                "channel": "account_orders/0/1234",
883                "auth": "token",
884            }),
885        );
886    }
887
888    #[rstest]
889    fn test_subscribe_request_debug_redacts_auth_token() {
890        let token = "schnorr-signature-bytes-do-not-leak";
891        let request = LighterWsRequest::subscribe_auth("account_all/123", token);
892
893        let dbg = format!("{request:?}");
894
895        assert!(
896            !dbg.contains(token),
897            "Debug output must not contain the auth token, found: {dbg}",
898        );
899        assert!(dbg.contains("authed"), "Debug should include authed flag");
900    }
901
902    #[rstest]
903    #[case(LighterWsChannelKind::OrderBook)]
904    #[case(LighterWsChannelKind::Ticker)]
905    #[case(LighterWsChannelKind::Trade)]
906    #[case(LighterWsChannelKind::Candle)]
907    #[case(LighterWsChannelKind::MarketStats)]
908    #[case(LighterWsChannelKind::SpotMarketStats)]
909    #[case(LighterWsChannelKind::AccountAll)]
910    #[case(LighterWsChannelKind::AccountOrders)]
911    #[case(LighterWsChannelKind::AccountAllOrders)]
912    #[case(LighterWsChannelKind::AccountAllTrades)]
913    #[case(LighterWsChannelKind::AccountAllPositions)]
914    #[case(LighterWsChannelKind::AccountAllAssets)]
915    #[case(LighterWsChannelKind::Height)]
916    fn test_channel_kind_wire_round_trip(#[case] kind: LighterWsChannelKind) {
917        assert_eq!(
918            LighterWsChannelKind::from_wire_str(kind.as_wire_str()),
919            Some(kind),
920        );
921    }
922
923    #[rstest]
924    #[case("unknown_channel")]
925    #[case("ORDER_BOOK")]
926    #[case("")]
927    #[case("order_book:0")]
928    fn test_channel_kind_unknown_returns_none(#[case] input: &str) {
929        assert_eq!(LighterWsChannelKind::from_wire_str(input), None);
930    }
931
932    #[rstest]
933    fn test_order_book_frame_deserializes() {
934        let frame: LighterWsFrame = serde_json::from_str(WS_ORDER_BOOK_UPDATE).unwrap();
935
936        match frame {
937            LighterWsFrame::OrderBook {
938                channel,
939                order_book,
940                timestamp,
941                ..
942            } => {
943                assert_eq!(channel, Ustr::from("order_book:0"));
944                assert_eq!(order_book.asks.len(), 1);
945                assert_eq!(
946                    order_book.asks[0].price,
947                    Decimal::from_str("2064.54").unwrap()
948                );
949                assert_eq!(timestamp, 1_774_884_082_326);
950            }
951            _ => panic!("expected order book frame"),
952        }
953    }
954
955    #[rstest]
956    fn test_trade_frame_deserializes() {
957        let frame: LighterWsFrame = serde_json::from_str(WS_TRADE_UPDATE).unwrap();
958
959        match frame {
960            LighterWsFrame::Trade { trades, nonce, .. } => {
961                assert_eq!(nonce, 8_630_448_841);
962                assert_eq!(trades.len(), 1);
963                assert_eq!(trades[0].trade_id_str.as_deref(), Some("16164557907"));
964            }
965            _ => panic!("expected trade frame"),
966        }
967    }
968
969    #[rstest]
970    fn test_trade_frame_deserializes_null_liquidations() {
971        let payload = serde_json::json!({
972            "type": "update/trade",
973            "channel": "trade:1",
974            "liquidation_trades": null,
975            "nonce": 1,
976            "trades": []
977        });
978
979        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
980
981        match frame {
982            LighterWsFrame::Trade {
983                liquidation_trades,
984                trades,
985                ..
986            } => {
987                assert!(liquidation_trades.is_empty());
988                assert!(trades.is_empty());
989            }
990            _ => panic!("expected trade frame"),
991        }
992    }
993
994    #[rstest]
995    fn test_trade_frame_deserializes_object_trades() {
996        let mut payload: Value = serde_json::from_str(WS_TRADE_UPDATE).unwrap();
997        let trades = payload.get_mut("trades").unwrap().take();
998        payload["trades"] = serde_json::json!({ "0": trades });
999
1000        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1001
1002        match frame {
1003            LighterWsFrame::Trade { trades, .. } => {
1004                assert_eq!(trades.len(), 1);
1005                assert_eq!(trades[0].trade_id_str.as_deref(), Some("16164557907"));
1006            }
1007            _ => panic!("expected trade frame"),
1008        }
1009    }
1010
1011    #[rstest]
1012    fn test_ticker_frame_deserializes() {
1013        let frame: LighterWsFrame = serde_json::from_str(WS_TICKER_UPDATE).unwrap();
1014
1015        match frame {
1016            LighterWsFrame::Ticker {
1017                channel,
1018                nonce,
1019                ticker,
1020                timestamp,
1021                ..
1022            } => {
1023                assert_eq!(channel, Ustr::from("ticker:0"));
1024                assert_eq!(nonce, 9_182_390_020);
1025                assert_eq!(ticker.s, Ustr::from("ETH"));
1026                assert_eq!(ticker.a.price, Decimal::from_str("2064.48").unwrap());
1027                assert_eq!(ticker.b.size, Decimal::from_str("1.0392").unwrap());
1028                assert_eq!(timestamp, 1_774_883_844_933);
1029            }
1030            _ => panic!("expected ticker frame"),
1031        }
1032    }
1033
1034    // The venue tags the initial state for each public stream as
1035    // `subscribed/<channel>` and only switches to `update/<channel>` for
1036    // incremental frames; the snapshot variants must round-trip even though
1037    // they share field shapes with their `update/*` counterparts.
1038    #[rstest]
1039    fn test_order_book_snapshot_frame_deserializes() {
1040        let frame: LighterWsFrame = serde_json::from_str(WS_ORDER_BOOK_SUBSCRIBED).unwrap();
1041
1042        match frame {
1043            LighterWsFrame::OrderBookSnapshot {
1044                channel,
1045                order_book,
1046                timestamp,
1047                ..
1048            } => {
1049                assert_eq!(channel, Ustr::from("order_book:0"));
1050                assert_eq!(order_book.bids.len(), 1);
1051                assert_eq!(
1052                    order_book.bids[0].price,
1053                    Decimal::from_str("2000.00").unwrap()
1054                );
1055                assert_eq!(order_book.asks.len(), 2);
1056                assert_eq!(
1057                    order_book.asks[0].price,
1058                    Decimal::from_str("2325.00").unwrap()
1059                );
1060                assert_eq!(order_book.nonce, 904_845);
1061                assert_eq!(timestamp, 1_778_138_582_602);
1062            }
1063            _ => panic!("expected order book snapshot frame, was {frame:?}"),
1064        }
1065    }
1066
1067    #[rstest]
1068    fn test_empty_order_book_snapshot_frame_deserializes() {
1069        let frame: LighterWsFrame = serde_json::from_str(WS_ORDER_BOOK_SUBSCRIBED_EMPTY).unwrap();
1070
1071        match frame {
1072            LighterWsFrame::OrderBookSnapshot {
1073                channel,
1074                last_updated_at,
1075                order_book,
1076                timestamp,
1077                ..
1078            } => {
1079                assert_eq!(channel, Ustr::from("order_book:39"));
1080                assert_eq!(last_updated_at, 0);
1081                assert!(order_book.asks.is_empty());
1082                assert!(order_book.bids.is_empty());
1083                assert_eq!(order_book.offset, 1);
1084                assert_eq!(order_book.nonce, 0);
1085                assert_eq!(timestamp, 1_778_138_582_602);
1086            }
1087            _ => panic!("expected empty order book snapshot frame, was {frame:?}"),
1088        }
1089    }
1090
1091    #[rstest]
1092    fn test_ticker_snapshot_frame_deserializes() {
1093        let frame: LighterWsFrame = serde_json::from_str(WS_TICKER_SUBSCRIBED).unwrap();
1094
1095        match frame {
1096            LighterWsFrame::TickerSnapshot {
1097                channel,
1098                nonce,
1099                ticker,
1100                timestamp,
1101                ..
1102            } => {
1103                assert_eq!(channel, Ustr::from("ticker:0"));
1104                assert_eq!(nonce, 904_895);
1105                assert_eq!(ticker.s, Ustr::from("ETH"));
1106                assert_eq!(ticker.a.price, Decimal::from_str("2325.00").unwrap());
1107                assert_eq!(ticker.b.price, Decimal::from_str("2000.00").unwrap());
1108                assert_eq!(timestamp, 1_778_138_582_640);
1109            }
1110            _ => panic!("expected ticker snapshot frame, was {frame:?}"),
1111        }
1112    }
1113
1114    #[rstest]
1115    fn test_empty_ticker_snapshot_frame_deserializes() {
1116        let frame: LighterWsFrame = serde_json::from_str(WS_TICKER_SUBSCRIBED_EMPTY).unwrap();
1117
1118        match frame {
1119            LighterWsFrame::TickerSnapshot {
1120                channel,
1121                last_updated_at,
1122                nonce,
1123                ticker,
1124                timestamp,
1125                ..
1126            } => {
1127                assert_eq!(channel, Ustr::from("ticker:39"));
1128                assert_eq!(last_updated_at, 0);
1129                assert_eq!(nonce, 2_475_051);
1130                assert_eq!(ticker.s, Ustr::from("ADA"));
1131                assert_eq!(ticker.a.price, Decimal::ZERO);
1132                assert_eq!(ticker.a.size, Decimal::ZERO);
1133                assert_eq!(ticker.b.price, Decimal::ZERO);
1134                assert_eq!(ticker.b.size, Decimal::ZERO);
1135                assert_eq!(timestamp, 1_778_138_582_640);
1136            }
1137            _ => panic!("expected empty ticker snapshot frame, was {frame:?}"),
1138        }
1139    }
1140
1141    #[rstest]
1142    fn test_trade_snapshot_frame_deserializes() {
1143        let frame: LighterWsFrame = serde_json::from_str(WS_TRADE_SUBSCRIBED).unwrap();
1144
1145        match frame {
1146            LighterWsFrame::TradeSnapshot {
1147                channel,
1148                nonce,
1149                trades,
1150                ..
1151            } => {
1152                assert_eq!(channel, Ustr::from("trade:0"));
1153                assert_eq!(nonce, 8_630_448_841);
1154                assert_eq!(trades.len(), 1);
1155                assert_eq!(trades[0].trade_id_str.as_deref(), Some("16164557907"));
1156            }
1157            _ => panic!("expected trade snapshot frame, was {frame:?}"),
1158        }
1159    }
1160
1161    #[rstest]
1162    fn test_market_stats_frame_deserializes_single_payload() {
1163        let frame: LighterWsFrame = serde_json::from_str(WS_MARKET_STATS_UPDATE_SINGLE).unwrap();
1164
1165        match frame {
1166            LighterWsFrame::MarketStats {
1167                channel,
1168                market_stats: LighterMarketStatsPayload::One(stats),
1169                timestamp,
1170            } => {
1171                assert_eq!(channel, Ustr::from("market_stats:0"));
1172                assert_eq!(stats.symbol, Ustr::from("ETH"));
1173                assert_eq!(stats.market_id, 0);
1174                assert_eq!(stats.mark_price, Decimal::from_str("2064.47").unwrap());
1175                assert_eq!(
1176                    stats.daily_base_token_volume,
1177                    Decimal::new(1_999_586_931, 4),
1178                );
1179                assert_eq!(timestamp, 1_774_883_844_933);
1180            }
1181            _ => panic!("expected single market stats frame"),
1182        }
1183    }
1184
1185    #[rstest]
1186    fn test_market_stats_subscribed_frame_deserializes_single_payload() {
1187        let frame: LighterWsFrame =
1188            serde_json::from_str(WS_MARKET_STATS_SUBSCRIBED_SINGLE).unwrap();
1189
1190        match frame {
1191            LighterWsFrame::MarketStats {
1192                channel,
1193                market_stats: LighterMarketStatsPayload::One(stats),
1194                timestamp,
1195            } => {
1196                assert_eq!(channel, Ustr::from("market_stats:1"));
1197                assert_eq!(stats.symbol, Ustr::from("BTC"));
1198                assert_eq!(stats.market_id, 1);
1199                assert_eq!(stats.mark_price, Decimal::from_str("64356.3").unwrap());
1200                assert_eq!(timestamp, 1_780_546_209_291);
1201            }
1202            _ => panic!("expected subscribed market stats frame"),
1203        }
1204    }
1205
1206    #[rstest]
1207    fn test_market_stats_frame_deserializes_all_payload() {
1208        let frame: LighterWsFrame = serde_json::from_str(WS_MARKET_STATS_UPDATE_ALL).unwrap();
1209
1210        match frame {
1211            LighterWsFrame::MarketStats {
1212                market_stats: LighterMarketStatsPayload::All(stats),
1213                ..
1214            } => {
1215                assert_eq!(stats.len(), 1);
1216                let stats = stats.get(&Ustr::from("0")).unwrap();
1217                assert_eq!(stats.symbol, Ustr::from("ETH"));
1218                assert_eq!(
1219                    stats.open_interest,
1220                    Decimal::from_str("27250.8411").unwrap()
1221                );
1222            }
1223            _ => panic!("expected all market stats frame"),
1224        }
1225    }
1226
1227    #[rstest]
1228    fn test_spot_market_stats_frame_deserializes_single_payload() {
1229        let frame: LighterWsFrame =
1230            serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_SINGLE).unwrap();
1231
1232        match frame {
1233            LighterWsFrame::SpotMarketStats {
1234                channel,
1235                spot_market_stats: LighterSpotMarketStatsPayload::One(stats),
1236                timestamp,
1237            } => {
1238                assert_eq!(channel, Ustr::from("spot_market_stats:2048"));
1239                assert_eq!(stats.symbol, Ustr::from("USDC"));
1240                assert_eq!(stats.market_id, 2048);
1241                assert_eq!(stats.mid_price, Decimal::from_str("1.000001").unwrap());
1242                assert_eq!(stats.daily_base_token_volume, Decimal::from(1000));
1243                assert_eq!(timestamp, 1_774_883_844_933);
1244            }
1245            _ => panic!("expected single spot market stats frame"),
1246        }
1247    }
1248
1249    #[rstest]
1250    fn test_spot_market_stats_subscribed_frame_deserializes_single_payload() {
1251        let frame: LighterWsFrame =
1252            serde_json::from_str(WS_SPOT_MARKET_STATS_SUBSCRIBED_SINGLE).unwrap();
1253
1254        match frame {
1255            LighterWsFrame::SpotMarketStats {
1256                channel,
1257                spot_market_stats: LighterSpotMarketStatsPayload::One(stats),
1258                timestamp,
1259            } => {
1260                assert_eq!(channel, Ustr::from("spot_market_stats:2048"));
1261                assert_eq!(stats.symbol, Ustr::from("USDC"));
1262                assert_eq!(stats.market_id, 2048);
1263                assert_eq!(stats.mid_price, Decimal::from_str("1.000001").unwrap());
1264                assert_eq!(timestamp, 1_774_883_844_933);
1265            }
1266            _ => panic!("expected subscribed spot market stats frame"),
1267        }
1268    }
1269
1270    #[rstest]
1271    fn test_spot_market_stats_frame_deserializes_all_payload() {
1272        let frame: LighterWsFrame = serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_ALL).unwrap();
1273
1274        match frame {
1275            LighterWsFrame::SpotMarketStats {
1276                spot_market_stats: LighterSpotMarketStatsPayload::All(stats),
1277                ..
1278            } => {
1279                assert_eq!(stats.len(), 1);
1280                let stats = stats.get(&Ustr::from("2048")).unwrap();
1281                assert_eq!(stats.symbol, Ustr::from("USDC"));
1282                assert_eq!(
1283                    stats.last_trade_price,
1284                    Decimal::from_str("1.000002").unwrap()
1285                );
1286            }
1287            _ => panic!("expected all spot market stats frame"),
1288        }
1289    }
1290
1291    #[rstest]
1292    fn test_account_all_assets_frame_deserializes() {
1293        // Fixture is the captured production no-position payload: USDC
1294        // sits at asset_id=3, balance=10 on spot, margin_balance=40 on
1295        // perp, margin_mode="disabled", no spot-order reservation.
1296        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
1297
1298        match frame {
1299            LighterWsFrame::AccountAllAssets {
1300                assets,
1301                channel,
1302                timestamp,
1303            } => {
1304                assert_eq!(channel, Ustr::from("account_all_assets:1234"));
1305                let asset = assets.get(&Ustr::from("3")).unwrap();
1306                assert_eq!(asset.symbol, Ustr::from("USDC"));
1307                assert_eq!(asset.asset_id, 3);
1308                assert_eq!(asset.balance, Decimal::from_str("10.000000").unwrap());
1309                assert_eq!(asset.locked_balance, Decimal::ZERO);
1310                assert_eq!(
1311                    asset.margin_balance,
1312                    Decimal::from_str("40.000000").unwrap()
1313                );
1314                assert_eq!(asset.margin_mode, Ustr::from("disabled"));
1315                assert_eq!(timestamp, 1_781_161_199_648);
1316            }
1317            _ => panic!("expected account all assets frame"),
1318        }
1319    }
1320
1321    #[rstest]
1322    fn test_account_all_assets_subscribed_frame_deserializes() {
1323        let payload = serde_json::json!({
1324            "type": "subscribed/account_all_assets",
1325            "channel": "account_all_assets:1234",
1326            "timestamp": 1778751230509u64,
1327            "assets": {
1328                "3": {
1329                    "asset_id": 3,
1330                    "balance": "9.660200",
1331                    "locked_balance": "0.000000",
1332                    "margin_balance": "9.955800",
1333                    "margin_mode": "disabled",
1334                    "symbol": "USDC"
1335                }
1336            }
1337        });
1338
1339        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1340
1341        match frame {
1342            LighterWsFrame::AccountAllAssets { assets, .. } => {
1343                assert_eq!(
1344                    assets.get(&Ustr::from("3")).unwrap().symbol,
1345                    Ustr::from("USDC")
1346                );
1347            }
1348            _ => panic!("expected account all assets frame"),
1349        }
1350    }
1351
1352    #[rstest]
1353    fn test_account_orders_frame_deserializes() {
1354        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
1355
1356        match frame {
1357            LighterWsFrame::AccountOrders {
1358                account,
1359                channel,
1360                orders,
1361                ..
1362            } => {
1363                assert_eq!(account, 1234);
1364                assert_eq!(channel, Ustr::from("account_orders:0:1234"));
1365                let market_orders = orders.get(&Ustr::from("0")).unwrap();
1366                assert_eq!(market_orders.len(), 1);
1367                assert_eq!(market_orders[0].order_id, "281476929510110");
1368                assert_eq!(
1369                    market_orders[0].filled_base_amount,
1370                    Decimal::from_str("0.0020").unwrap(),
1371                );
1372            }
1373            _ => panic!("expected account orders frame, was {frame:?}"),
1374        }
1375    }
1376
1377    #[rstest]
1378    fn test_account_all_orders_subscribed_frame_deserializes_empty_side() {
1379        let frame: LighterWsFrame = serde_json::from_str(
1380            r#"{
1381                "type": "subscribed/account_all_orders",
1382                "channel": "account_all_orders:1234",
1383                "orders": {
1384                    "3": [{
1385                        "order_index": 1,
1386                        "client_order_index": 2,
1387                        "order_id": "1",
1388                        "client_order_id": "2",
1389                        "market_index": 3,
1390                        "owner_account_index": 1234,
1391                        "initial_base_amount": "100",
1392                        "price": "0.100000",
1393                        "nonce": 1,
1394                        "remaining_base_amount": "100",
1395                        "is_ask": false,
1396                        "base_size": 100,
1397                        "base_price": 100000,
1398                        "filled_base_amount": "0",
1399                        "filled_quote_amount": "0.000000",
1400                        "side": "",
1401                        "type": "limit",
1402                        "time_in_force": "good-till-time",
1403                        "reduce_only": false,
1404                        "trigger_price": "0.000000",
1405                        "order_expiry": 1781170441337,
1406                        "status": "open",
1407                        "trigger_status": "na",
1408                        "trigger_time": 0,
1409                        "parent_order_index": 0,
1410                        "parent_order_id": "0",
1411                        "to_trigger_order_id_0": "0",
1412                        "to_trigger_order_id_1": "0",
1413                        "to_cancel_order_id_0": "0",
1414                        "integrator_fee_collector_index": "",
1415                        "integrator_taker_fee": "",
1416                        "integrator_maker_fee": "",
1417                        "block_height": 1,
1418                        "timestamp": 1778751241,
1419                        "created_at": 1778751241,
1420                        "updated_at": 1778751241,
1421                        "transaction_time": 1778751241772524
1422                    }]
1423                }
1424            }"#,
1425        )
1426        .unwrap();
1427
1428        match frame {
1429            LighterWsFrame::AccountAllOrders { orders, .. } => {
1430                let order = &orders.get(&Ustr::from("3")).unwrap()[0];
1431                assert_eq!(order.side, None);
1432                assert!(!order.is_ask);
1433            }
1434            _ => panic!("expected account all orders frame"),
1435        }
1436    }
1437
1438    #[rstest]
1439    fn test_account_all_trades_frame_deserializes() {
1440        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
1441
1442        match frame {
1443            LighterWsFrame::AccountAllTrades { channel, trades } => {
1444                assert_eq!(channel, Ustr::from("account_all_trades:1234"));
1445                let market_trades = trades.get(&Ustr::from("0")).unwrap();
1446                assert_eq!(market_trades.len(), 1);
1447                assert_eq!(market_trades[0].bid_account_id, 1234);
1448                assert_eq!(market_trades[0].taker_fee, Some(196));
1449            }
1450            _ => panic!("expected account all trades frame, was {frame:?}"),
1451        }
1452    }
1453
1454    #[rstest]
1455    fn test_account_all_positions_frame_deserializes() {
1456        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1457
1458        match frame {
1459            LighterWsFrame::AccountAllPositions {
1460                channel, positions, ..
1461            } => {
1462                assert_eq!(channel, Ustr::from("account_all_positions:1234"));
1463                let position = positions.get(&Ustr::from("0")).unwrap();
1464                assert_eq!(position.market_id, 0);
1465                assert_eq!(position.position, Decimal::from_str("1.5000").unwrap());
1466                assert_eq!(position.sign, 1);
1467            }
1468            _ => panic!("expected account all positions frame, was {frame:?}"),
1469        }
1470    }
1471
1472    #[rstest]
1473    fn test_height_frame_deserializes() {
1474        let frame: LighterWsFrame = serde_json::from_str(WS_HEIGHT_UPDATE).unwrap();
1475
1476        match frame {
1477            LighterWsFrame::Height {
1478                channel,
1479                height,
1480                timestamp,
1481            } => {
1482                assert_eq!(channel, Ustr::from("height"));
1483                assert_eq!(height, 227_535_532);
1484                assert_eq!(timestamp, 1_774_883_844_933);
1485            }
1486            _ => panic!("expected height frame"),
1487        }
1488    }
1489
1490    #[rstest]
1491    fn test_candle_channel_subscription_channel_uses_slash() {
1492        let channel = LighterWsChannel::Candle {
1493            market_index: 0,
1494            resolution: LighterCandleResolution::OneMinute,
1495        };
1496
1497        assert_eq!(channel.subscription_channel(), "candle/0/1m");
1498    }
1499
1500    #[rstest]
1501    fn test_candle_channel_topic_key_uses_colon() {
1502        let channel = LighterWsChannel::Candle {
1503            market_index: 7,
1504            resolution: LighterCandleResolution::FiveMinute,
1505        };
1506
1507        assert_eq!(channel.topic_key(), "candle:7:5m");
1508    }
1509
1510    #[rstest]
1511    fn test_candle_channel_does_not_require_auth() {
1512        let channel = LighterWsChannel::Candle {
1513            market_index: 0,
1514            resolution: LighterCandleResolution::OneMinute,
1515        };
1516
1517        assert!(!channel.requires_auth());
1518    }
1519
1520    #[rstest]
1521    fn test_candle_snapshot_frame_deserializes() {
1522        let frame: LighterWsFrame = serde_json::from_str(WS_CANDLE_SUBSCRIBED).unwrap();
1523
1524        match frame {
1525            LighterWsFrame::CandleSnapshot {
1526                channel,
1527                candles,
1528                timestamp,
1529            } => {
1530                assert_eq!(channel, Ustr::from("candle:0:1m"));
1531                assert_eq!(timestamp, 1_778_821_471_842);
1532                assert_eq!(candles.len(), 1);
1533                let candle = &candles[0];
1534                assert_eq!(candle.t, 1_778_821_440_000);
1535                assert_eq!(candle.o, Decimal::from_str("2264.2").unwrap());
1536                assert_eq!(candle.h, Decimal::from_str("2264.34").unwrap());
1537                assert_eq!(candle.l, Decimal::from_str("2263.36").unwrap());
1538                assert_eq!(candle.c, Decimal::from_str("2263.97").unwrap());
1539                // f64 JSON numbers round-trip through `deserialize_decimal::visit_f64`
1540                // which converts via `Decimal::try_from(f64)`; the resulting value is the
1541                // nearest representable decimal to the float, not the JSON literal text.
1542                assert_eq!(candle.v, Decimal::from_str("13.2237").unwrap());
1543                assert_eq!(
1544                    candle.quote_volume,
1545                    Decimal::from_str("29934.60001199998").unwrap(),
1546                );
1547                assert_eq!(candle.i, 19_993_571_166);
1548            }
1549            _ => panic!("expected candle snapshot frame"),
1550        }
1551    }
1552
1553    #[rstest]
1554    fn test_candle_update_frame_deserializes() {
1555        let frame: LighterWsFrame = serde_json::from_str(WS_CANDLE_UPDATE).unwrap();
1556
1557        match frame {
1558            LighterWsFrame::Candle {
1559                channel,
1560                candles,
1561                timestamp,
1562            } => {
1563                assert_eq!(channel, Ustr::from("candle:0:1m"));
1564                assert_eq!(timestamp, 1_778_821_473_331);
1565                assert_eq!(candles.len(), 1);
1566                assert_eq!(candles[0].t, 1_778_821_440_000);
1567                assert_eq!(candles[0].c, Decimal::from_str("2263.89").unwrap());
1568            }
1569            _ => panic!("expected candle update frame"),
1570        }
1571    }
1572}