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