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_native,
24        deserialize_decimal_or_zero, 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 TX_BATCH_ID_PREFIX: &str = "tx-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 = "shutdown")]
463    Shutdown { close_in_ms: u64 },
464    #[serde(rename = "subscribed/order_book")]
465    OrderBookSnapshot {
466        channel: Ustr,
467        #[serde(default)]
468        last_updated_at: u64,
469        offset: i64,
470        order_book: LighterWsOrderBook,
471        timestamp: u64,
472    },
473    #[serde(rename = "update/order_book")]
474    OrderBook {
475        channel: Ustr,
476        last_updated_at: u64,
477        offset: i64,
478        order_book: LighterWsOrderBook,
479        timestamp: u64,
480    },
481    #[serde(rename = "subscribed/ticker")]
482    TickerSnapshot {
483        channel: Ustr,
484        #[serde(default)]
485        last_updated_at: u64,
486        nonce: i64,
487        ticker: LighterTicker,
488        timestamp: u64,
489    },
490    #[serde(rename = "update/ticker")]
491    Ticker {
492        channel: Ustr,
493        last_updated_at: u64,
494        nonce: i64,
495        ticker: LighterTicker,
496        timestamp: u64,
497    },
498    #[serde(rename = "update/market_stats", alias = "subscribed/market_stats")]
499    MarketStats {
500        channel: Ustr,
501        market_stats: LighterMarketStatsPayload,
502        timestamp: u64,
503    },
504    #[serde(
505        rename = "update/spot_market_stats",
506        alias = "subscribed/spot_market_stats"
507    )]
508    SpotMarketStats {
509        channel: Ustr,
510        spot_market_stats: LighterSpotMarketStatsPayload,
511        timestamp: u64,
512    },
513    #[serde(rename = "subscribed/trade")]
514    TradeSnapshot {
515        channel: Ustr,
516        #[serde(default, deserialize_with = "deserialize_trade_vec")]
517        liquidation_trades: Vec<LighterTrade>,
518        nonce: i64,
519        #[serde(default, deserialize_with = "deserialize_trade_vec")]
520        trades: Vec<LighterTrade>,
521    },
522    #[serde(rename = "update/trade")]
523    Trade {
524        channel: Ustr,
525        #[serde(default, deserialize_with = "deserialize_trade_vec")]
526        liquidation_trades: Vec<LighterTrade>,
527        nonce: i64,
528        #[serde(default, deserialize_with = "deserialize_trade_vec")]
529        trades: Vec<LighterTrade>,
530    },
531    #[serde(rename = "update/account_orders", alias = "subscribed/account_orders")]
532    AccountOrders {
533        account: i64,
534        channel: Ustr,
535        nonce: i64,
536        orders: AHashMap<Ustr, Vec<LighterOrder>>,
537    },
538    #[serde(
539        rename = "update/account_all_orders",
540        alias = "subscribed/account_all_orders"
541    )]
542    AccountAllOrders {
543        channel: Ustr,
544        orders: AHashMap<Ustr, Vec<LighterOrder>>,
545    },
546    #[serde(rename = "subscribed/account_all_trades")]
547    AccountAllTradesSnapshot {
548        channel: Ustr,
549        #[serde(default, deserialize_with = "deserialize_trade_vec")]
550        trades: Vec<LighterTrade>,
551        #[serde(deserialize_with = "deserialize_decimal")]
552        total_volume: Decimal,
553        #[serde(deserialize_with = "deserialize_decimal")]
554        monthly_volume: Decimal,
555        #[serde(deserialize_with = "deserialize_decimal")]
556        weekly_volume: Decimal,
557        #[serde(deserialize_with = "deserialize_decimal")]
558        daily_volume: Decimal,
559    },
560    #[serde(rename = "update/account_all_trades")]
561    AccountAllTrades {
562        channel: Ustr,
563        trades: AHashMap<Ustr, Vec<LighterTrade>>,
564    },
565    #[serde(rename = "subscribed/account_all_positions")]
566    AccountAllPositionsSnapshot {
567        channel: Ustr,
568        positions: AHashMap<Ustr, LighterPosition>,
569        #[serde(default)]
570        shares: Vec<LighterPoolShares>,
571        #[serde(default, deserialize_with = "deserialize_optional_decimal_map")]
572        last_funding_round: Option<AHashMap<Ustr, Decimal>>,
573        #[serde(default, deserialize_with = "deserialize_optional_decimal_map")]
574        last_funding_discount: Option<AHashMap<Ustr, Decimal>>,
575    },
576    #[serde(rename = "update/account_all_positions")]
577    AccountAllPositions {
578        channel: Ustr,
579        positions: AHashMap<Ustr, LighterPosition>,
580        #[serde(default)]
581        shares: Vec<LighterPoolShares>,
582        #[serde(default, deserialize_with = "deserialize_optional_decimal_map")]
583        last_funding_round: Option<AHashMap<Ustr, Decimal>>,
584        #[serde(default, deserialize_with = "deserialize_optional_decimal_map")]
585        last_funding_discount: Option<AHashMap<Ustr, Decimal>>,
586    },
587    #[serde(
588        rename = "update/account_all_assets",
589        alias = "subscribed/account_all_assets"
590    )]
591    AccountAllAssets {
592        assets: AHashMap<Ustr, LighterAsset>,
593        channel: Ustr,
594        timestamp: u64,
595    },
596    #[serde(rename = "update/user_stats", alias = "subscribed/user_stats")]
597    UserStats {
598        channel: Ustr,
599        stats: LighterUserStats,
600        timestamp: u64,
601    },
602    #[serde(rename = "update/height", alias = "subscribed/height")]
603    Height {
604        channel: Ustr,
605        height: i64,
606        timestamp: u64,
607    },
608    #[serde(rename = "subscribed/candle")]
609    CandleSnapshot {
610        channel: Ustr,
611        candles: Vec<LighterWsCandle>,
612        timestamp: u64,
613    },
614    #[serde(rename = "update/candle")]
615    Candle {
616        channel: Ustr,
617        candles: Vec<LighterWsCandle>,
618        timestamp: u64,
619    },
620}
621
622#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
623pub struct LighterWsCandle {
624    pub t: i64,
625    #[serde(deserialize_with = "deserialize_decimal")]
626    pub o: Decimal,
627    #[serde(deserialize_with = "deserialize_decimal")]
628    pub h: Decimal,
629    #[serde(deserialize_with = "deserialize_decimal")]
630    pub l: Decimal,
631    #[serde(deserialize_with = "deserialize_decimal")]
632    pub c: Decimal,
633    #[serde(deserialize_with = "deserialize_decimal")]
634    pub v: Decimal,
635    #[serde(default, rename = "V", deserialize_with = "deserialize_decimal")]
636    pub quote_volume: Decimal,
637    #[serde(default)]
638    pub i: i64,
639}
640
641#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
642pub struct LighterWsOrderBook {
643    pub code: i32,
644    pub asks: Vec<LighterPriceLevel>,
645    pub bids: Vec<LighterPriceLevel>,
646    pub offset: i64,
647    pub nonce: i64,
648    pub last_updated_at: u64,
649    pub begin_nonce: i64,
650}
651
652#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
653pub struct LighterTicker {
654    pub s: Ustr,
655    pub a: LighterPriceLevel,
656    pub b: LighterPriceLevel,
657    pub last_updated_at: u64,
658}
659
660#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
661#[serde(untagged)]
662pub enum LighterMarketStatsPayload {
663    All(AHashMap<Ustr, LighterMarketStats>),
664    One(Box<LighterMarketStats>),
665}
666
667#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
668pub struct LighterMarketStats {
669    pub symbol: Ustr,
670    pub market_id: i64,
671    #[serde(deserialize_with = "deserialize_decimal_from_str")]
672    pub index_price: Decimal,
673    #[serde(deserialize_with = "deserialize_decimal_from_str")]
674    pub mark_price: Decimal,
675    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
676    pub mid_price: Decimal,
677    #[serde(deserialize_with = "deserialize_decimal_from_str")]
678    pub open_interest: Decimal,
679    #[serde(deserialize_with = "deserialize_decimal_from_str")]
680    pub open_interest_limit: Decimal,
681    #[serde(deserialize_with = "deserialize_decimal_from_str")]
682    pub funding_clamp_small: Decimal,
683    #[serde(deserialize_with = "deserialize_decimal_from_str")]
684    pub funding_clamp_big: Decimal,
685    #[serde(deserialize_with = "deserialize_decimal_from_str")]
686    pub last_trade_price: Decimal,
687    #[serde(deserialize_with = "deserialize_decimal_from_str")]
688    pub current_funding_rate: Decimal,
689    #[serde(deserialize_with = "deserialize_decimal_from_str")]
690    pub funding_rate: Decimal,
691    pub funding_timestamp: u64,
692    #[serde(deserialize_with = "deserialize_decimal")]
693    pub daily_base_token_volume: Decimal,
694    #[serde(deserialize_with = "deserialize_decimal")]
695    pub daily_quote_token_volume: Decimal,
696    #[serde(deserialize_with = "deserialize_decimal")]
697    pub daily_price_low: Decimal,
698    #[serde(deserialize_with = "deserialize_decimal")]
699    pub daily_price_high: Decimal,
700    #[serde(deserialize_with = "deserialize_decimal")]
701    pub daily_price_change: Decimal,
702}
703
704#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
705#[serde(untagged)]
706pub enum LighterSpotMarketStatsPayload {
707    All(AHashMap<Ustr, LighterSpotMarketStats>),
708    One(Box<LighterSpotMarketStats>),
709}
710
711#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
712pub struct LighterSpotMarketStats {
713    pub symbol: Ustr,
714    pub market_id: i64,
715    #[serde(deserialize_with = "deserialize_decimal_from_str")]
716    pub index_price: Decimal,
717    #[serde(deserialize_with = "deserialize_decimal_or_zero")]
718    pub mid_price: Decimal,
719    #[serde(deserialize_with = "deserialize_decimal_from_str")]
720    pub last_trade_price: Decimal,
721    #[serde(deserialize_with = "deserialize_decimal")]
722    pub daily_base_token_volume: Decimal,
723    #[serde(deserialize_with = "deserialize_decimal")]
724    pub daily_quote_token_volume: Decimal,
725    #[serde(deserialize_with = "deserialize_decimal")]
726    pub daily_price_low: Decimal,
727    #[serde(deserialize_with = "deserialize_decimal")]
728    pub daily_price_high: Decimal,
729    #[serde(deserialize_with = "deserialize_decimal")]
730    pub daily_price_change: Decimal,
731}
732
733#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
734pub struct LighterPosition {
735    pub market_id: i64,
736    pub symbol: Ustr,
737    #[serde(deserialize_with = "deserialize_decimal_from_str")]
738    pub initial_margin_fraction: Decimal,
739    pub open_order_count: i64,
740    pub pending_order_count: i64,
741    pub position_tied_order_count: i64,
742    pub sign: i8,
743    #[serde(deserialize_with = "deserialize_decimal_from_str")]
744    pub position: Decimal,
745    #[serde(deserialize_with = "deserialize_decimal_from_str")]
746    pub avg_entry_price: Decimal,
747    #[serde(deserialize_with = "deserialize_decimal_from_str")]
748    pub position_value: Decimal,
749    #[serde(deserialize_with = "deserialize_decimal_from_str")]
750    pub unrealized_pnl: Decimal,
751    #[serde(deserialize_with = "deserialize_decimal_from_str")]
752    pub realized_pnl: Decimal,
753    #[serde(deserialize_with = "deserialize_decimal_from_str")]
754    pub liquidation_price: Decimal,
755    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
756    pub total_funding_paid_out: Option<Decimal>,
757    pub margin_mode: i32,
758    #[serde(deserialize_with = "deserialize_decimal_from_str")]
759    pub allocated_margin: Decimal,
760    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
761    pub total_discount: Option<Decimal>,
762}
763
764#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
765pub struct LighterPoolShares {
766    pub public_pool_index: i64,
767    pub shares_amount: i64,
768    #[serde(deserialize_with = "deserialize_decimal_from_str")]
769    pub entry_usdc: Decimal,
770    #[serde(deserialize_with = "deserialize_decimal_from_str")]
771    pub principal_amount: Decimal,
772    pub entry_timestamp: u64,
773}
774
775/// Inner shape of the `user_stats.stats.cross_stats` and `.total_stats`
776/// substructs. Every field is a stringified decimal on the wire and
777/// denominated in the deployment settlement currency.
778#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
779pub struct LighterUserStatsScoped {
780    #[serde(deserialize_with = "deserialize_decimal_from_str")]
781    pub available_balance: Decimal,
782    #[serde(deserialize_with = "deserialize_decimal_from_str")]
783    pub buying_power: Decimal,
784    #[serde(deserialize_with = "deserialize_decimal_from_str")]
785    pub collateral: Decimal,
786    #[serde(deserialize_with = "deserialize_decimal_from_str")]
787    pub leverage: Decimal,
788    #[serde(deserialize_with = "deserialize_decimal_from_str")]
789    pub margin_usage: Decimal,
790    #[serde(deserialize_with = "deserialize_decimal_from_str")]
791    pub portfolio_value: Decimal,
792}
793
794/// Body of the `user_stats` frame. Top-level equity numbers mirror
795/// `total_stats`; `cross_stats` reports cross-margin equity only.
796#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
797pub struct LighterUserStats {
798    #[serde(default)]
799    pub account_trading_mode: i32,
800    #[serde(deserialize_with = "deserialize_decimal_from_str")]
801    pub available_balance: Decimal,
802    #[serde(deserialize_with = "deserialize_decimal_from_str")]
803    pub buying_power: Decimal,
804    #[serde(deserialize_with = "deserialize_decimal_from_str")]
805    pub collateral: Decimal,
806    #[serde(deserialize_with = "deserialize_decimal_from_str")]
807    pub leverage: Decimal,
808    #[serde(deserialize_with = "deserialize_decimal_from_str")]
809    pub margin_usage: Decimal,
810    #[serde(deserialize_with = "deserialize_decimal_from_str")]
811    pub portfolio_value: Decimal,
812    pub cross_stats: Option<LighterUserStatsScoped>,
813    pub total_stats: Option<LighterUserStatsScoped>,
814}
815
816#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
817pub struct LighterAsset {
818    pub symbol: Ustr,
819    pub asset_id: i16,
820    /// Spot-side balance for this asset.
821    #[serde(deserialize_with = "deserialize_decimal_from_str")]
822    pub balance: Decimal,
823    /// Spot-side amount reserved by resting spot orders.
824    #[serde(deserialize_with = "deserialize_decimal_from_str")]
825    pub locked_balance: Decimal,
826    /// Perp-side collateral for this asset. Defaults to zero when the wire
827    /// omits the field (spot-only frames).
828    #[serde(default, deserialize_with = "deserialize_decimal_from_str")]
829    pub margin_balance: Decimal,
830    /// Per-asset margin treatment. Observed values: "disabled" (asset not
831    /// pledged as collateral). Defaults to empty when the wire omits it.
832    #[serde(default)]
833    pub margin_mode: Ustr,
834}
835
836#[derive(Deserialize)]
837struct JsonDecimal(#[serde(deserialize_with = "deserialize_decimal_native")] Decimal);
838
839fn deserialize_optional_decimal_map<'de, D: serde::Deserializer<'de>>(
840    deserializer: D,
841) -> Result<Option<AHashMap<Ustr, Decimal>>, D::Error> {
842    let values = Option::<AHashMap<Ustr, JsonDecimal>>::deserialize(deserializer)?;
843    Ok(values.map(|values| {
844        values
845            .into_iter()
846            .map(|(key, value)| (key, value.0))
847            .collect()
848    }))
849}
850
851fn deserialize_trade_vec<'de, D>(deserializer: D) -> Result<Vec<LighterTrade>, D::Error>
852where
853    D: serde::Deserializer<'de>,
854{
855    struct TradeVecVisitor;
856
857    impl<'de> Visitor<'de> for TradeVecVisitor {
858        type Value = Vec<LighterTrade>;
859
860        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
861            formatter.write_str("trade array, object keyed by market, or null")
862        }
863
864        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
865            Ok(Vec::new())
866        }
867
868        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
869            Ok(Vec::new())
870        }
871
872        fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
873            let mut trades = Vec::with_capacity(seq.size_hint().unwrap_or(0));
874            while let Some(trade) = seq.next_element::<LighterTrade>()? {
875                trades.push(trade);
876            }
877            Ok(trades)
878        }
879
880        fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
881            let mut trades = Vec::new();
882            while let Some((_, mut market_trades)) =
883                map.next_entry::<IgnoredAny, Vec<LighterTrade>>()?
884            {
885                trades.append(&mut market_trades);
886            }
887            Ok(trades)
888        }
889    }
890
891    deserializer.deserialize_any(TradeVecVisitor)
892}
893
894#[cfg(test)]
895mod tests {
896    use std::str::FromStr;
897
898    use rstest::rstest;
899    use serde_json::Value;
900
901    use super::*;
902
903    const WS_ORDER_BOOK_UPDATE: &str = include_str!("../../test_data/ws_order_book_update.json");
904    const WS_ORDER_BOOK_SUBSCRIBED: &str =
905        include_str!("../../test_data/ws_order_book_subscribed.json");
906    const WS_ORDER_BOOK_SUBSCRIBED_EMPTY: &str =
907        include_str!("../../test_data/ws_order_book_subscribed_empty.json");
908    const WS_TRADE_UPDATE: &str = include_str!("../../test_data/ws_trade_update.json");
909    const WS_TRADE_SUBSCRIBED: &str = include_str!("../../test_data/ws_trade_subscribed.json");
910    const WS_TICKER_UPDATE: &str = include_str!("../../test_data/ws_ticker_update.json");
911    const WS_TICKER_SUBSCRIBED: &str = include_str!("../../test_data/ws_ticker_subscribed.json");
912    const WS_TICKER_SUBSCRIBED_EMPTY: &str =
913        include_str!("../../test_data/ws_ticker_subscribed_empty.json");
914    const WS_MARKET_STATS_UPDATE_SINGLE: &str =
915        include_str!("../../test_data/ws_market_stats_update_single.json");
916    const WS_MARKET_STATS_SUBSCRIBED_SINGLE: &str =
917        include_str!("../../test_data/ws_market_stats_subscribed_single.json");
918    const WS_MARKET_STATS_UPDATE_ALL: &str =
919        include_str!("../../test_data/ws_market_stats_update_all.json");
920    const WS_MARKET_STATS_UPDATE_SINGLE_WIDENED: &str =
921        include_str!("../../test_data/ws_market_stats_update_single_widened.json");
922    const WS_SPOT_MARKET_STATS_UPDATE_SINGLE: &str =
923        include_str!("../../test_data/ws_spot_market_stats_update_single.json");
924    const WS_SPOT_MARKET_STATS_SUBSCRIBED_SINGLE: &str =
925        include_str!("../../test_data/ws_spot_market_stats_subscribed_single.json");
926    const WS_SPOT_MARKET_STATS_UPDATE_ALL: &str =
927        include_str!("../../test_data/ws_spot_market_stats_update_all.json");
928    const WS_SPOT_MARKET_STATS_UPDATE_SINGLE_WIDENED: &str =
929        include_str!("../../test_data/ws_spot_market_stats_update_single_widened.json");
930    const WS_SPOT_MARKET_STATS_SUBSCRIBED_SINGLE_EMPTY_MID: &str =
931        include_str!("../../test_data/ws_spot_market_stats_subscribed_single_empty_mid.json");
932    const WS_ACCOUNT_ALL_ASSETS_UPDATE: &str =
933        include_str!("../../test_data/ws_account_all_assets_update.json");
934    const WS_ACCOUNT_ORDERS_UPDATE: &str =
935        include_str!("../../test_data/ws_account_orders_update.json");
936    const WS_ACCOUNT_ALL_TRADES_UPDATE: &str =
937        include_str!("../../test_data/ws_account_all_trades_update.json");
938    const WS_ACCOUNT_ALL_POSITIONS_UPDATE: &str =
939        include_str!("../../test_data/ws_account_all_positions_update.json");
940    const WS_HEIGHT_UPDATE: &str = include_str!("../../test_data/ws_height_update.json");
941    const WS_CANDLE_SUBSCRIBED: &str = include_str!("../../test_data/ws_candle_subscribed.json");
942    const WS_CANDLE_UPDATE: &str = include_str!("../../test_data/ws_candle_update.json");
943
944    #[rstest]
945    #[case("subscribed/account_all_positions")]
946    #[case("update/account_all_positions")]
947    fn test_funding_decimal_maps(#[case] kind: &str) {
948        let value = serde_json::json!({
949            "type": kind,
950            "channel": "account_all_positions:7",
951            "positions": {},
952            "last_funding_round": {"1": 9007199254740993u64, "3": 1.25},
953            "last_funding_discount": {
954                "2": "0.1234567890123456789012345678",
955                "4": "0.12345678901234567890123456789"
956            }
957        });
958        let text = value.to_string();
959
960        for frame in [
961            serde_json::from_str::<LighterWsFrame>(&text).unwrap(),
962            serde_json::from_value(value).unwrap(),
963        ] {
964            let (round, discount) = funding_maps(frame);
965            assert_eq!(
966                round.unwrap(),
967                AHashMap::from_iter([
968                    (Ustr::from("1"), Decimal::from(9_007_199_254_740_993u64)),
969                    (Ustr::from("3"), Decimal::new(125, 2))
970                ])
971            );
972            assert_eq!(
973                discount.unwrap(),
974                AHashMap::from_iter([
975                    (
976                        Ustr::from("2"),
977                        Decimal::from_str_exact("0.1234567890123456789012345678").unwrap()
978                    ),
979                    (
980                        Ustr::from("4"),
981                        Decimal::from_str_exact("0.1234567890123456789012345679").unwrap()
982                    ),
983                ])
984            );
985        }
986    }
987
988    #[rstest]
989    fn test_funding_decimal_maps_absent(
990        #[values("subscribed/account_all_positions", "update/account_all_positions")] kind: &str,
991        #[values(false, true)] explicit_null: bool,
992    ) {
993        let mut value = serde_json::json!({
994            "type": kind,
995            "channel": "account_all_positions:7",
996            "positions": {},
997        });
998
999        if explicit_null {
1000            value["last_funding_round"] = serde_json::Value::Null;
1001            value["last_funding_discount"] = serde_json::Value::Null;
1002        }
1003
1004        let text = value.to_string();
1005
1006        for frame in [
1007            serde_json::from_str::<LighterWsFrame>(&text).unwrap(),
1008            serde_json::from_value(value).unwrap(),
1009        ] {
1010            assert_eq!(funding_maps(frame), (None, None));
1011        }
1012    }
1013
1014    type FundingMap = Option<AHashMap<Ustr, Decimal>>;
1015
1016    fn funding_maps(frame: LighterWsFrame) -> (FundingMap, FundingMap) {
1017        match frame {
1018            LighterWsFrame::AccountAllPositionsSnapshot {
1019                last_funding_round,
1020                last_funding_discount,
1021                ..
1022            }
1023            | LighterWsFrame::AccountAllPositions {
1024                last_funding_round,
1025                last_funding_discount,
1026                ..
1027            } => (last_funding_round, last_funding_discount),
1028            _ => panic!("expected account positions"),
1029        }
1030    }
1031
1032    #[rstest]
1033    fn test_subscription_request_serializes_public_channel() {
1034        let channel = LighterWsChannel::OrderBook(0).subscription_channel();
1035        let request = LighterWsRequest::subscribe(channel);
1036
1037        let json = serde_json::to_string(&request).unwrap();
1038
1039        assert_eq!(
1040            serde_json::from_str::<Value>(&json).unwrap(),
1041            serde_json::json!({
1042                "type": "subscribe",
1043                "channel": "order_book/0",
1044            }),
1045        );
1046    }
1047
1048    #[rstest]
1049    fn test_subscription_request_serializes_auth_channel() {
1050        let channel = LighterWsChannel::AccountOrders {
1051            market_index: 0,
1052            account_index: 1234,
1053        }
1054        .subscription_channel();
1055        let request = LighterWsRequest::subscribe_auth(channel, "token");
1056
1057        let json = serde_json::to_string(&request).unwrap();
1058
1059        assert_eq!(
1060            serde_json::from_str::<Value>(&json).unwrap(),
1061            serde_json::json!({
1062                "type": "subscribe",
1063                "channel": "account_orders/0/1234",
1064                "auth": "token",
1065            }),
1066        );
1067    }
1068
1069    #[rstest]
1070    fn test_subscribe_request_debug_redacts_auth_token() {
1071        let token = "schnorr-signature-bytes-do-not-leak";
1072        let mut request = LighterWsRequest::subscribe_auth("account_all/123", token);
1073
1074        let dbg = format!("{request:?}");
1075
1076        assert!(
1077            !dbg.contains(token),
1078            "Debug output must not contain the auth token, found: {dbg}",
1079        );
1080        assert!(dbg.contains(REDACTED));
1081
1082        request.zeroize();
1083        assert!(matches!(
1084            &request,
1085            LighterWsRequest::Subscribe { auth: None, .. }
1086        ));
1087    }
1088
1089    #[rstest]
1090    #[case(LighterWsChannelKind::OrderBook)]
1091    #[case(LighterWsChannelKind::Ticker)]
1092    #[case(LighterWsChannelKind::Trade)]
1093    #[case(LighterWsChannelKind::Candle)]
1094    #[case(LighterWsChannelKind::MarketStats)]
1095    #[case(LighterWsChannelKind::SpotMarketStats)]
1096    #[case(LighterWsChannelKind::AccountAll)]
1097    #[case(LighterWsChannelKind::AccountOrders)]
1098    #[case(LighterWsChannelKind::AccountAllOrders)]
1099    #[case(LighterWsChannelKind::AccountAllTrades)]
1100    #[case(LighterWsChannelKind::AccountAllPositions)]
1101    #[case(LighterWsChannelKind::AccountAllAssets)]
1102    #[case(LighterWsChannelKind::Height)]
1103    fn test_channel_kind_wire_round_trip(#[case] kind: LighterWsChannelKind) {
1104        assert_eq!(
1105            LighterWsChannelKind::from_wire_str(kind.as_wire_str()),
1106            Some(kind),
1107        );
1108    }
1109
1110    #[rstest]
1111    #[case("unknown_channel")]
1112    #[case("ORDER_BOOK")]
1113    #[case("")]
1114    #[case("order_book:0")]
1115    fn test_channel_kind_unknown_returns_none(#[case] input: &str) {
1116        assert_eq!(LighterWsChannelKind::from_wire_str(input), None);
1117    }
1118
1119    #[rstest]
1120    fn test_order_book_frame_deserializes() {
1121        let frame: LighterWsFrame = serde_json::from_str(WS_ORDER_BOOK_UPDATE).unwrap();
1122
1123        match frame {
1124            LighterWsFrame::OrderBook {
1125                channel,
1126                order_book,
1127                timestamp,
1128                ..
1129            } => {
1130                assert_eq!(channel, Ustr::from("order_book:0"));
1131                assert_eq!(order_book.asks.len(), 1);
1132                assert_eq!(
1133                    order_book.asks[0].price,
1134                    Decimal::from_str("2064.54").unwrap()
1135                );
1136                assert_eq!(timestamp, 1_774_884_082_326);
1137            }
1138            _ => panic!("expected order book frame"),
1139        }
1140    }
1141
1142    #[rstest]
1143    fn test_trade_frame_deserializes() {
1144        let frame: LighterWsFrame = serde_json::from_str(WS_TRADE_UPDATE).unwrap();
1145
1146        match frame {
1147            LighterWsFrame::Trade { trades, nonce, .. } => {
1148                assert_eq!(nonce, 8_630_448_841);
1149                assert_eq!(trades.len(), 1);
1150                assert_eq!(trades[0].trade_id_str.as_deref(), Some("16164557907"));
1151            }
1152            _ => panic!("expected trade frame"),
1153        }
1154    }
1155
1156    #[rstest]
1157    fn test_trade_frame_deserializes_null_liquidations() {
1158        let payload = serde_json::json!({
1159            "type": "update/trade",
1160            "channel": "trade:1",
1161            "liquidation_trades": null,
1162            "nonce": 1,
1163            "trades": []
1164        });
1165
1166        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1167
1168        match frame {
1169            LighterWsFrame::Trade {
1170                liquidation_trades,
1171                trades,
1172                ..
1173            } => {
1174                assert!(liquidation_trades.is_empty());
1175                assert!(trades.is_empty());
1176            }
1177            _ => panic!("expected trade frame"),
1178        }
1179    }
1180
1181    #[rstest]
1182    fn test_trade_frame_deserializes_object_trades() {
1183        let mut payload: Value = serde_json::from_str(WS_TRADE_UPDATE).unwrap();
1184        let trades = payload.get_mut("trades").unwrap().take();
1185        payload["trades"] = serde_json::json!({ "0": trades });
1186
1187        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1188
1189        match frame {
1190            LighterWsFrame::Trade { trades, .. } => {
1191                assert_eq!(trades.len(), 1);
1192                assert_eq!(trades[0].trade_id_str.as_deref(), Some("16164557907"));
1193            }
1194            _ => panic!("expected trade frame"),
1195        }
1196    }
1197
1198    #[rstest]
1199    fn test_ticker_frame_deserializes() {
1200        let frame: LighterWsFrame = serde_json::from_str(WS_TICKER_UPDATE).unwrap();
1201
1202        match frame {
1203            LighterWsFrame::Ticker {
1204                channel,
1205                nonce,
1206                ticker,
1207                timestamp,
1208                ..
1209            } => {
1210                assert_eq!(channel, Ustr::from("ticker:0"));
1211                assert_eq!(nonce, 9_182_390_020);
1212                assert_eq!(ticker.s, Ustr::from("ETH"));
1213                assert_eq!(ticker.a.price, Decimal::from_str("2064.48").unwrap());
1214                assert_eq!(ticker.b.size, Decimal::from_str("1.0392").unwrap());
1215                assert_eq!(timestamp, 1_774_883_844_933);
1216            }
1217            _ => panic!("expected ticker frame"),
1218        }
1219    }
1220
1221    // The venue tags the initial state for each public stream as
1222    // `subscribed/<channel>` and only switches to `update/<channel>` for
1223    // incremental frames; the snapshot variants must round-trip even though
1224    // they share field shapes with their `update/*` counterparts.
1225    #[rstest]
1226    fn test_order_book_snapshot_frame_deserializes() {
1227        let frame: LighterWsFrame = serde_json::from_str(WS_ORDER_BOOK_SUBSCRIBED).unwrap();
1228
1229        match frame {
1230            LighterWsFrame::OrderBookSnapshot {
1231                channel,
1232                order_book,
1233                timestamp,
1234                ..
1235            } => {
1236                assert_eq!(channel, Ustr::from("order_book:0"));
1237                assert_eq!(order_book.bids.len(), 1);
1238                assert_eq!(
1239                    order_book.bids[0].price,
1240                    Decimal::from_str("2000.00").unwrap()
1241                );
1242                assert_eq!(order_book.asks.len(), 2);
1243                assert_eq!(
1244                    order_book.asks[0].price,
1245                    Decimal::from_str("2325.00").unwrap()
1246                );
1247                assert_eq!(order_book.nonce, 904_845);
1248                assert_eq!(timestamp, 1_778_138_582_602);
1249            }
1250            _ => panic!("expected order book snapshot frame, was {frame:?}"),
1251        }
1252    }
1253
1254    #[rstest]
1255    fn test_empty_order_book_snapshot_frame_deserializes() {
1256        let frame: LighterWsFrame = serde_json::from_str(WS_ORDER_BOOK_SUBSCRIBED_EMPTY).unwrap();
1257
1258        match frame {
1259            LighterWsFrame::OrderBookSnapshot {
1260                channel,
1261                last_updated_at,
1262                order_book,
1263                timestamp,
1264                ..
1265            } => {
1266                assert_eq!(channel, Ustr::from("order_book:39"));
1267                assert_eq!(last_updated_at, 0);
1268                assert!(order_book.asks.is_empty());
1269                assert!(order_book.bids.is_empty());
1270                assert_eq!(order_book.offset, 1);
1271                assert_eq!(order_book.nonce, 0);
1272                assert_eq!(timestamp, 1_778_138_582_602);
1273            }
1274            _ => panic!("expected empty order book snapshot frame, was {frame:?}"),
1275        }
1276    }
1277
1278    #[rstest]
1279    fn test_ticker_snapshot_frame_deserializes() {
1280        let frame: LighterWsFrame = serde_json::from_str(WS_TICKER_SUBSCRIBED).unwrap();
1281
1282        match frame {
1283            LighterWsFrame::TickerSnapshot {
1284                channel,
1285                nonce,
1286                ticker,
1287                timestamp,
1288                ..
1289            } => {
1290                assert_eq!(channel, Ustr::from("ticker:0"));
1291                assert_eq!(nonce, 904_895);
1292                assert_eq!(ticker.s, Ustr::from("ETH"));
1293                assert_eq!(ticker.a.price, Decimal::from_str("2325.00").unwrap());
1294                assert_eq!(ticker.b.price, Decimal::from_str("2000.00").unwrap());
1295                assert_eq!(timestamp, 1_778_138_582_640);
1296            }
1297            _ => panic!("expected ticker snapshot frame, was {frame:?}"),
1298        }
1299    }
1300
1301    #[rstest]
1302    fn test_empty_ticker_snapshot_frame_deserializes() {
1303        let frame: LighterWsFrame = serde_json::from_str(WS_TICKER_SUBSCRIBED_EMPTY).unwrap();
1304
1305        match frame {
1306            LighterWsFrame::TickerSnapshot {
1307                channel,
1308                last_updated_at,
1309                nonce,
1310                ticker,
1311                timestamp,
1312                ..
1313            } => {
1314                assert_eq!(channel, Ustr::from("ticker:39"));
1315                assert_eq!(last_updated_at, 0);
1316                assert_eq!(nonce, 2_475_051);
1317                assert_eq!(ticker.s, Ustr::from("ADA"));
1318                assert_eq!(ticker.a.price, Decimal::ZERO);
1319                assert_eq!(ticker.a.size, Decimal::ZERO);
1320                assert_eq!(ticker.b.price, Decimal::ZERO);
1321                assert_eq!(ticker.b.size, Decimal::ZERO);
1322                assert_eq!(timestamp, 1_778_138_582_640);
1323            }
1324            _ => panic!("expected empty ticker snapshot frame, was {frame:?}"),
1325        }
1326    }
1327
1328    #[rstest]
1329    fn test_trade_snapshot_frame_deserializes() {
1330        let frame: LighterWsFrame = serde_json::from_str(WS_TRADE_SUBSCRIBED).unwrap();
1331
1332        match frame {
1333            LighterWsFrame::TradeSnapshot {
1334                channel,
1335                nonce,
1336                trades,
1337                ..
1338            } => {
1339                assert_eq!(channel, Ustr::from("trade:0"));
1340                assert_eq!(nonce, 8_630_448_841);
1341                assert_eq!(trades.len(), 1);
1342                assert_eq!(trades[0].trade_id_str.as_deref(), Some("16164557907"));
1343            }
1344            _ => panic!("expected trade snapshot frame, was {frame:?}"),
1345        }
1346    }
1347
1348    #[rstest]
1349    fn test_market_stats_frame_deserializes_single_payload() {
1350        let frame: LighterWsFrame = serde_json::from_str(WS_MARKET_STATS_UPDATE_SINGLE).unwrap();
1351
1352        match frame {
1353            LighterWsFrame::MarketStats {
1354                channel,
1355                market_stats: LighterMarketStatsPayload::One(stats),
1356                timestamp,
1357            } => {
1358                assert_eq!(channel, Ustr::from("market_stats:0"));
1359                assert_eq!(stats.symbol, Ustr::from("ETH"));
1360                assert_eq!(stats.market_id, 0);
1361                assert_eq!(stats.mark_price, Decimal::from_str("2064.47").unwrap());
1362                assert_eq!(
1363                    stats.daily_base_token_volume,
1364                    Decimal::new(1_999_586_931, 4),
1365                );
1366                assert_eq!(timestamp, 1_774_883_844_933);
1367            }
1368            _ => panic!("expected single market stats frame"),
1369        }
1370    }
1371
1372    #[rstest]
1373    fn test_market_stats_subscribed_frame_deserializes_single_payload() {
1374        let frame: LighterWsFrame =
1375            serde_json::from_str(WS_MARKET_STATS_SUBSCRIBED_SINGLE).unwrap();
1376
1377        match frame {
1378            LighterWsFrame::MarketStats {
1379                channel,
1380                market_stats: LighterMarketStatsPayload::One(stats),
1381                timestamp,
1382            } => {
1383                assert_eq!(channel, Ustr::from("market_stats:1"));
1384                assert_eq!(stats.symbol, Ustr::from("BTC"));
1385                assert_eq!(stats.market_id, 1);
1386                assert_eq!(stats.mark_price, Decimal::from_str("64356.3").unwrap());
1387                assert_eq!(timestamp, 1_780_546_209_291);
1388            }
1389            _ => panic!("expected subscribed market stats frame"),
1390        }
1391    }
1392
1393    #[rstest]
1394    fn test_market_stats_frame_deserializes_all_payload() {
1395        let frame: LighterWsFrame = serde_json::from_str(WS_MARKET_STATS_UPDATE_ALL).unwrap();
1396
1397        match frame {
1398            LighterWsFrame::MarketStats {
1399                market_stats: LighterMarketStatsPayload::All(stats),
1400                ..
1401            } => {
1402                assert_eq!(stats.len(), 1);
1403                let stats = stats.get(&Ustr::from("0")).unwrap();
1404                assert_eq!(stats.symbol, Ustr::from("ETH"));
1405                assert_eq!(
1406                    stats.open_interest,
1407                    Decimal::from_str("27250.8411").unwrap()
1408                );
1409            }
1410            _ => panic!("expected all market stats frame"),
1411        }
1412    }
1413
1414    #[rstest]
1415    fn test_market_stats_frame_deserializes_widened_market_id() {
1416        let frame: LighterWsFrame =
1417            serde_json::from_str(WS_MARKET_STATS_UPDATE_SINGLE_WIDENED).unwrap();
1418
1419        match frame {
1420            LighterWsFrame::MarketStats {
1421                channel,
1422                market_stats: LighterMarketStatsPayload::One(stats),
1423                timestamp,
1424            } => {
1425                assert_eq!(channel, Ustr::from("market_stats:40000"));
1426                assert_eq!(stats.symbol, Ustr::from("FUTURE"));
1427                assert_eq!(stats.market_id, 40_000);
1428                assert_eq!(stats.mark_price, Decimal::from_str("12.47").unwrap());
1429                assert_eq!(timestamp, 1_774_883_844_933);
1430            }
1431            _ => panic!("expected single market stats frame"),
1432        }
1433    }
1434
1435    #[rstest]
1436    fn test_spot_market_stats_frame_deserializes_widened_market_id() {
1437        let frame: LighterWsFrame =
1438            serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_SINGLE_WIDENED).unwrap();
1439
1440        match frame {
1441            LighterWsFrame::SpotMarketStats {
1442                channel,
1443                spot_market_stats: LighterSpotMarketStatsPayload::One(stats),
1444                timestamp,
1445            } => {
1446                assert_eq!(channel, Ustr::from("spot_market_stats:50000"));
1447                assert_eq!(stats.symbol, Ustr::from("FUTURE/USDC"));
1448                assert_eq!(stats.market_id, 50_000);
1449                assert_eq!(stats.mid_price, Decimal::from_str("1.000001").unwrap());
1450                assert_eq!(timestamp, 1_774_883_844_933);
1451            }
1452            _ => panic!("expected single spot market stats frame"),
1453        }
1454    }
1455
1456    #[rstest]
1457    fn test_spot_market_stats_frame_deserializes_empty_mid_as_zero() {
1458        let frame: LighterWsFrame =
1459            serde_json::from_str(WS_SPOT_MARKET_STATS_SUBSCRIBED_SINGLE_EMPTY_MID).unwrap();
1460
1461        match frame {
1462            LighterWsFrame::SpotMarketStats {
1463                channel,
1464                spot_market_stats: LighterSpotMarketStatsPayload::One(stats),
1465                timestamp,
1466            } => {
1467                assert_eq!(channel, Ustr::from("spot_market_stats:4098"));
1468                assert_eq!(stats.symbol, Ustr::from("ETH/USDC"));
1469                assert_eq!(stats.market_id, 4098);
1470                assert_eq!(stats.mid_price, Decimal::ZERO);
1471                assert_eq!(stats.index_price, Decimal::from_str("2471.940000").unwrap());
1472                assert_eq!(timestamp, 1_789_706_424_060);
1473            }
1474            _ => panic!("expected single spot market stats frame"),
1475        }
1476    }
1477
1478    #[rstest]
1479    fn test_spot_market_stats_frame_deserializes_single_payload() {
1480        let frame: LighterWsFrame =
1481            serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_SINGLE).unwrap();
1482
1483        match frame {
1484            LighterWsFrame::SpotMarketStats {
1485                channel,
1486                spot_market_stats: LighterSpotMarketStatsPayload::One(stats),
1487                timestamp,
1488            } => {
1489                assert_eq!(channel, Ustr::from("spot_market_stats:2048"));
1490                assert_eq!(stats.symbol, Ustr::from("USDC"));
1491                assert_eq!(stats.market_id, 2048);
1492                assert_eq!(stats.mid_price, Decimal::from_str("1.000001").unwrap());
1493                assert_eq!(stats.daily_base_token_volume, Decimal::from(1000));
1494                assert_eq!(timestamp, 1_774_883_844_933);
1495            }
1496            _ => panic!("expected single spot market stats frame"),
1497        }
1498    }
1499
1500    #[rstest]
1501    fn test_spot_market_stats_subscribed_frame_deserializes_single_payload() {
1502        let frame: LighterWsFrame =
1503            serde_json::from_str(WS_SPOT_MARKET_STATS_SUBSCRIBED_SINGLE).unwrap();
1504
1505        match frame {
1506            LighterWsFrame::SpotMarketStats {
1507                channel,
1508                spot_market_stats: LighterSpotMarketStatsPayload::One(stats),
1509                timestamp,
1510            } => {
1511                assert_eq!(channel, Ustr::from("spot_market_stats:2048"));
1512                assert_eq!(stats.symbol, Ustr::from("USDC"));
1513                assert_eq!(stats.market_id, 2048);
1514                assert_eq!(stats.mid_price, Decimal::from_str("1.000001").unwrap());
1515                assert_eq!(timestamp, 1_774_883_844_933);
1516            }
1517            _ => panic!("expected subscribed spot market stats frame"),
1518        }
1519    }
1520
1521    #[rstest]
1522    fn test_spot_market_stats_frame_deserializes_all_payload() {
1523        let frame: LighterWsFrame = serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_ALL).unwrap();
1524
1525        match frame {
1526            LighterWsFrame::SpotMarketStats {
1527                spot_market_stats: LighterSpotMarketStatsPayload::All(stats),
1528                ..
1529            } => {
1530                assert_eq!(stats.len(), 1);
1531                let stats = stats.get(&Ustr::from("2048")).unwrap();
1532                assert_eq!(stats.symbol, Ustr::from("USDC"));
1533                assert_eq!(
1534                    stats.last_trade_price,
1535                    Decimal::from_str("1.000002").unwrap()
1536                );
1537            }
1538            _ => panic!("expected all spot market stats frame"),
1539        }
1540    }
1541
1542    #[rstest]
1543    fn test_account_all_assets_frame_deserializes() {
1544        // Fixture is the captured production no-position payload: USDC
1545        // sits at asset_id=3, balance=10 on spot, margin_balance=40 on
1546        // perp, margin_mode="disabled", no spot-order reservation.
1547        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
1548
1549        match frame {
1550            LighterWsFrame::AccountAllAssets {
1551                assets,
1552                channel,
1553                timestamp,
1554            } => {
1555                assert_eq!(channel, Ustr::from("account_all_assets:1234"));
1556                let asset = assets.get(&Ustr::from("3")).unwrap();
1557                assert_eq!(asset.symbol, Ustr::from("USDC"));
1558                assert_eq!(asset.asset_id, 3);
1559                assert_eq!(asset.balance, Decimal::from_str("10.000000").unwrap());
1560                assert_eq!(asset.locked_balance, Decimal::ZERO);
1561                assert_eq!(
1562                    asset.margin_balance,
1563                    Decimal::from_str("40.000000").unwrap()
1564                );
1565                assert_eq!(asset.margin_mode, Ustr::from("disabled"));
1566                assert_eq!(timestamp, 1_781_161_199_648);
1567            }
1568            _ => panic!("expected account all assets frame"),
1569        }
1570    }
1571
1572    #[rstest]
1573    fn test_account_all_assets_subscribed_frame_deserializes() {
1574        let payload = serde_json::json!({
1575            "type": "subscribed/account_all_assets",
1576            "channel": "account_all_assets:1234",
1577            "timestamp": 1778751230509u64,
1578            "assets": {
1579                "3": {
1580                    "asset_id": 3,
1581                    "balance": "9.660200",
1582                    "locked_balance": "0.000000",
1583                    "margin_balance": "9.955800",
1584                    "margin_mode": "disabled",
1585                    "symbol": "USDC"
1586                }
1587            }
1588        });
1589
1590        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1591
1592        match frame {
1593            LighterWsFrame::AccountAllAssets { assets, .. } => {
1594                assert_eq!(
1595                    assets.get(&Ustr::from("3")).unwrap().symbol,
1596                    Ustr::from("USDC")
1597                );
1598            }
1599            _ => panic!("expected account all assets frame"),
1600        }
1601    }
1602
1603    #[rstest]
1604    fn test_account_orders_frame_deserializes() {
1605        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
1606
1607        match frame {
1608            LighterWsFrame::AccountOrders {
1609                account,
1610                channel,
1611                orders,
1612                ..
1613            } => {
1614                assert_eq!(account, 1234);
1615                assert_eq!(channel, Ustr::from("account_orders:0:1234"));
1616                let market_orders = orders.get(&Ustr::from("0")).unwrap();
1617                assert_eq!(market_orders.len(), 1);
1618                assert_eq!(market_orders[0].order_id, "281476929510110");
1619                assert_eq!(market_orders[0].nonce, 281_474_720_725_346);
1620                assert_eq!(
1621                    market_orders[0].filled_base_amount,
1622                    Decimal::from_str("0.0020").unwrap(),
1623                );
1624            }
1625            _ => panic!("expected account orders frame, was {frame:?}"),
1626        }
1627    }
1628
1629    #[rstest]
1630    fn test_account_orders_subscribed_frame_deserializes() {
1631        let mut payload: serde_json::Value =
1632            serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
1633        payload["type"] = serde_json::json!("subscribed/account_orders");
1634
1635        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1636
1637        assert!(matches!(frame, LighterWsFrame::AccountOrders { .. }));
1638    }
1639
1640    #[rstest]
1641    fn test_account_all_orders_subscribed_frame_deserializes_empty_side() {
1642        let frame: LighterWsFrame = serde_json::from_str(
1643            r#"{
1644                "type": "subscribed/account_all_orders",
1645                "channel": "account_all_orders:1234",
1646                "orders": {
1647                    "3": [{
1648                        "order_index": 1,
1649                        "client_order_index": 2,
1650                        "order_id": "1",
1651                        "client_order_id": "2",
1652                        "market_index": 3,
1653                        "owner_account_index": 1234,
1654                        "initial_base_amount": "100",
1655                        "price": "0.100000",
1656                        "nonce": 1,
1657                        "remaining_base_amount": "100",
1658                        "is_ask": false,
1659                        "base_size": 100,
1660                        "base_price": 100000,
1661                        "filled_base_amount": "0",
1662                        "filled_quote_amount": "0.000000",
1663                        "side": "",
1664                        "type": "limit",
1665                        "time_in_force": "good-till-time",
1666                        "reduce_only": false,
1667                        "trigger_price": "0.000000",
1668                        "order_expiry": 1781170441337,
1669                        "status": "open",
1670                        "trigger_status": "na",
1671                        "trigger_time": 0,
1672                        "parent_order_index": 0,
1673                        "parent_order_id": "0",
1674                        "to_trigger_order_id_0": "0",
1675                        "to_trigger_order_id_1": "0",
1676                        "to_cancel_order_id_0": "0",
1677                        "integrator_fee_collector_index": "",
1678                        "integrator_taker_fee": "",
1679                        "integrator_maker_fee": "",
1680                        "block_height": 1,
1681                        "timestamp": 1778751241,
1682                        "created_at": 1778751241,
1683                        "updated_at": 1778751241,
1684                        "transaction_time": 1778751241772524
1685                    }]
1686                }
1687            }"#,
1688        )
1689        .unwrap();
1690
1691        match frame {
1692            LighterWsFrame::AccountAllOrders { orders, .. } => {
1693                let order = &orders.get(&Ustr::from("3")).unwrap()[0];
1694                assert_eq!(order.side, None);
1695                assert!(!order.is_ask);
1696            }
1697            _ => panic!("expected account all orders frame"),
1698        }
1699    }
1700
1701    #[rstest]
1702    fn test_account_all_trades_frame_deserializes() {
1703        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
1704
1705        match frame {
1706            LighterWsFrame::AccountAllTrades { channel, trades } => {
1707                assert_eq!(channel, Ustr::from("account_all_trades:1234"));
1708                let market_trades = trades.get(&Ustr::from("0")).unwrap();
1709                assert_eq!(market_trades.len(), 1);
1710                assert_eq!(market_trades[0].bid_account_id, 1234);
1711                assert_eq!(market_trades[0].taker_fee, Some(196));
1712            }
1713            _ => panic!("expected account all trades frame, was {frame:?}"),
1714        }
1715    }
1716
1717    #[rstest]
1718    fn test_account_all_positions_frame_deserializes() {
1719        let frame: LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1720
1721        match frame {
1722            LighterWsFrame::AccountAllPositions {
1723                channel, positions, ..
1724            } => {
1725                assert_eq!(channel, Ustr::from("account_all_positions:1234"));
1726                let position = positions.get(&Ustr::from("0")).unwrap();
1727                assert_eq!(position.market_id, 0);
1728                assert_eq!(position.position, Decimal::from_str("1.5000").unwrap());
1729                assert_eq!(position.sign, 1);
1730            }
1731            _ => panic!("expected account all positions frame, was {frame:?}"),
1732        }
1733    }
1734
1735    #[rstest]
1736    fn test_account_all_positions_snapshot_frame_deserializes() {
1737        let mut value: serde_json::Value =
1738            serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1739        value["type"] = serde_json::json!("subscribed/account_all_positions");
1740        let frame: LighterWsFrame = serde_json::from_value(value).unwrap();
1741
1742        match frame {
1743            LighterWsFrame::AccountAllPositionsSnapshot {
1744                channel, positions, ..
1745            } => {
1746                assert_eq!(channel, Ustr::from("account_all_positions:1234"));
1747                let position = positions.get(&Ustr::from("0")).unwrap();
1748                assert_eq!(position.market_id, 0);
1749                assert_eq!(position.position, Decimal::from_str("1.5000").unwrap());
1750                assert_eq!(position.sign, 1);
1751            }
1752            _ => panic!("expected account all positions snapshot, was {frame:?}"),
1753        }
1754    }
1755
1756    #[rstest]
1757    fn test_height_frame_deserializes() {
1758        let frame: LighterWsFrame = serde_json::from_str(WS_HEIGHT_UPDATE).unwrap();
1759
1760        match frame {
1761            LighterWsFrame::Height {
1762                channel,
1763                height,
1764                timestamp,
1765            } => {
1766                assert_eq!(channel, Ustr::from("height"));
1767                assert_eq!(height, 227_535_532);
1768                assert_eq!(timestamp, 1_774_883_844_933);
1769            }
1770            _ => panic!("expected height frame"),
1771        }
1772    }
1773
1774    #[rstest]
1775    fn test_height_subscribed_frame_deserializes() {
1776        let mut payload: serde_json::Value = serde_json::from_str(WS_HEIGHT_UPDATE).unwrap();
1777        payload["type"] = serde_json::json!("subscribed/height");
1778
1779        let frame: LighterWsFrame = serde_json::from_value(payload).unwrap();
1780
1781        assert!(matches!(frame, LighterWsFrame::Height { .. }));
1782    }
1783
1784    #[rstest]
1785    fn test_candle_channel_subscription_channel_uses_slash() {
1786        let channel = LighterWsChannel::Candle {
1787            market_index: 0,
1788            resolution: LighterCandleResolution::OneMinute,
1789        };
1790
1791        assert_eq!(channel.subscription_channel(), "candle/0/1m");
1792    }
1793
1794    #[rstest]
1795    fn test_candle_channel_topic_key_uses_colon() {
1796        let channel = LighterWsChannel::Candle {
1797            market_index: 7,
1798            resolution: LighterCandleResolution::FiveMinute,
1799        };
1800
1801        assert_eq!(channel.topic_key(), "candle:7:5m");
1802    }
1803
1804    #[rstest]
1805    fn test_candle_channel_does_not_require_auth() {
1806        let channel = LighterWsChannel::Candle {
1807            market_index: 0,
1808            resolution: LighterCandleResolution::OneMinute,
1809        };
1810
1811        assert!(!channel.requires_auth());
1812    }
1813
1814    #[rstest]
1815    fn test_candle_snapshot_frame_deserializes() {
1816        let frame: LighterWsFrame = serde_json::from_str(WS_CANDLE_SUBSCRIBED).unwrap();
1817
1818        match frame {
1819            LighterWsFrame::CandleSnapshot {
1820                channel,
1821                candles,
1822                timestamp,
1823            } => {
1824                assert_eq!(channel, Ustr::from("candle:0:1m"));
1825                assert_eq!(timestamp, 1_778_821_471_842);
1826                assert_eq!(candles.len(), 1);
1827                let candle = &candles[0];
1828                assert_eq!(candle.t, 1_778_821_440_000);
1829                assert_eq!(candle.o, Decimal::from_str("2264.2").unwrap());
1830                assert_eq!(candle.h, Decimal::from_str("2264.34").unwrap());
1831                assert_eq!(candle.l, Decimal::from_str("2263.36").unwrap());
1832                assert_eq!(candle.c, Decimal::from_str("2263.97").unwrap());
1833                let arbitrary_precision =
1834                    serde_json::from_str::<serde_json::Number>("79228162514264337593543950335")
1835                        .unwrap()
1836                        .to_string()
1837                        == "79228162514264337593543950335";
1838
1839                let (volume, quote_volume) = if arbitrary_precision {
1840                    ("13.223699999999997", "29934.600011999984")
1841                } else {
1842                    ("13.2237", "29934.60001199998")
1843                };
1844
1845                assert_eq!(candle.v, Decimal::from_str_exact(volume).unwrap());
1846                assert_eq!(
1847                    candle.quote_volume,
1848                    Decimal::from_str_exact(quote_volume).unwrap()
1849                );
1850                assert_eq!(candle.i, 19_993_571_166);
1851            }
1852            _ => panic!("expected candle snapshot frame"),
1853        }
1854    }
1855
1856    #[rstest]
1857    fn test_candle_update_frame_deserializes() {
1858        let frame: LighterWsFrame = serde_json::from_str(WS_CANDLE_UPDATE).unwrap();
1859
1860        match frame {
1861            LighterWsFrame::Candle {
1862                channel,
1863                candles,
1864                timestamp,
1865            } => {
1866                assert_eq!(channel, Ustr::from("candle:0:1m"));
1867                assert_eq!(timestamp, 1_778_821_473_331);
1868                assert_eq!(candles.len(), 1);
1869                assert_eq!(candles[0].t, 1_778_821_440_000);
1870                assert_eq!(candles[0].c, Decimal::from_str("2263.89").unwrap());
1871            }
1872            _ => panic!("expected candle update frame"),
1873        }
1874    }
1875
1876    #[rstest]
1877    #[case(0)]
1878    #[case(7350)]
1879    #[case(u64::MAX)]
1880    fn shutdown_notification_parses_deadline(#[case] close_in_ms: u64) {
1881        let frame: LighterWsFrame = serde_json::from_value(
1882            serde_json::json!({"type":"shutdown", "close_in_ms":close_in_ms}),
1883        )
1884        .unwrap();
1885
1886        match frame {
1887            LighterWsFrame::Shutdown {
1888                close_in_ms: actual,
1889            } => assert_eq!(actual, close_in_ms),
1890            other => panic!("expected shutdown, was {other:?}"),
1891        }
1892    }
1893
1894    #[rstest]
1895    #[case(serde_json::json!({"type":"shutdown"}))]
1896    #[case(serde_json::json!({"type":"shutdown", "close_in_ms":-1}))]
1897    #[case(serde_json::json!({"type":"shutdown", "close_in_ms":"7350"}))]
1898    fn shutdown_notification_rejects_invalid_deadline(#[case] frame: serde_json::Value) {
1899        assert!(serde_json::from_value::<LighterWsFrame>(frame).is_err());
1900    }
1901}