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