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