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