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