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