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