1use std::{
19 collections::VecDeque,
20 fmt::Debug,
21 sync::{
22 Arc,
23 atomic::{AtomicBool, Ordering},
24 },
25};
26
27use ahash::{AHashMap, AHashSet};
28use nautilus_core::{AtomicTime, nanos::UnixNanos, time::get_atomic_clock_realtime};
29use nautilus_model::{identifiers::AccountId, instruments::InstrumentAny};
30use nautilus_network::{
31 RECONNECTED,
32 error::SendError,
33 retry::{RetryManager, create_websocket_retry_manager},
34 websocket::{SubscriptionState, WebSocketClient},
35};
36use rust_decimal::Decimal;
37use tokio_tungstenite::tungstenite::Message;
38use ustr::Ustr;
39
40use super::{
41 account_state::LighterAccountStateReconciler,
42 error::LighterWsError,
43 messages::{
44 AccountStream, ExecutionReport, LighterAsset, LighterPosition, LighterUserStats,
45 LighterWsCandle, LighterWsChannel, LighterWsChannelKind, LighterWsFrame,
46 LighterWsOrderBook, LighterWsRequest, NautilusWsMessage, SendTxRejectionSource,
47 },
48 parse::{
49 parse_ws_bar, parse_ws_funding_rate_update, parse_ws_index_price_update,
50 parse_ws_mark_price_update, parse_ws_order_book_deltas, parse_ws_order_book_depth10,
51 parse_ws_position_status_report, parse_ws_quote_tick, parse_ws_spot_index_price_update,
52 parse_ws_trade_tick,
53 },
54};
55use crate::{
56 common::{
57 consts::{
58 LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED, LIGHTER_ERROR_CODE_TX_RANGE,
59 LIGHTER_INTEGRATOR_APPROVAL_DOCS_URL, SUBSCRIBE_INFLIGHT_MAX,
60 },
61 enums::LighterCandleResolution,
62 rate_limit::LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY,
63 },
64 http::models::{LighterOrder, LighterPriceLevel, LighterTrade},
65};
66
67const CTRL_TYPE_CONNECTED: &str = "connected";
71const CTRL_TYPE_SUBSCRIBED: &str = "subscribed";
72const CTRL_TYPE_UNSUBSCRIBED: &str = "unsubscribed";
73const CTRL_TYPE_PING: &str = "ping";
74const CTRL_TYPE_PONG: &str = "pong";
75const CTRL_TYPE_ERROR: &str = "error";
76const CTRL_TYPE_SEND_TX: &str = "jsonapi/sendtx";
77
78#[expect(
80 clippy::large_enum_variant,
81 reason = "commands are ephemeral and immediately consumed"
82)]
83pub enum HandlerCommand {
84 SetClient(WebSocketClient),
87 Disconnect,
89 Subscribe {
92 channel: LighterWsChannel,
93 auth: Option<String>,
94 },
95 Unsubscribe { channel: LighterWsChannel },
97 ResubscribeOrderBook { market_index: i16 },
99 InitializeInstruments(Vec<(i16, InstrumentAny)>),
101 UpdateInstrument {
103 market_index: i16,
104 instrument: InstrumentAny,
105 },
106 SetBookDeltasSub { market_index: i16, subscribed: bool },
109 SetDepth10Sub { market_index: i16, subscribed: bool },
112 SetExecutionContext {
117 account_id: AccountId,
118 account_index: i64,
119 },
120 SendTx {
126 tx_type: u8,
127 tx_info: Box<serde_json::value::RawValue>,
128 response_tx: tokio::sync::oneshot::Sender<Result<(), LighterWsError>>,
129 },
130}
131
132impl Debug for HandlerCommand {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 match self {
139 Self::SetClient(_) => f.write_str("SetClient(<WebSocketClient>)"),
140 Self::Disconnect => f.write_str("Disconnect"),
141 Self::Subscribe { channel, auth } => f
142 .debug_struct(stringify!(Subscribe))
143 .field("channel", channel)
144 .field("authed", &auth.is_some())
145 .finish(),
146 Self::Unsubscribe { channel } => f
147 .debug_struct(stringify!(Unsubscribe))
148 .field("channel", channel)
149 .finish(),
150 Self::ResubscribeOrderBook { market_index } => f
151 .debug_struct(stringify!(ResubscribeOrderBook))
152 .field("market_index", market_index)
153 .finish(),
154 Self::InitializeInstruments(instruments) => f
155 .debug_tuple(stringify!(InitializeInstruments))
156 .field(&instruments.len())
157 .finish(),
158 Self::UpdateInstrument { market_index, .. } => f
159 .debug_struct(stringify!(UpdateInstrument))
160 .field("market_index", market_index)
161 .finish(),
162 Self::SetBookDeltasSub {
163 market_index,
164 subscribed,
165 } => f
166 .debug_struct(stringify!(SetBookDeltasSub))
167 .field("market_index", market_index)
168 .field("subscribed", subscribed)
169 .finish(),
170 Self::SetDepth10Sub {
171 market_index,
172 subscribed,
173 } => f
174 .debug_struct(stringify!(SetDepth10Sub))
175 .field("market_index", market_index)
176 .field("subscribed", subscribed)
177 .finish(),
178 Self::SetExecutionContext {
179 account_id,
180 account_index,
181 } => f
182 .debug_struct(stringify!(SetExecutionContext))
183 .field("account_id", account_id)
184 .field("account_index", account_index)
185 .finish(),
186 Self::SendTx { tx_type, .. } => f
187 .debug_struct(stringify!(SendTx))
188 .field("tx_type", tx_type)
189 .field("tx_info", &"<redacted>")
190 .finish(),
191 }
192 }
193}
194
195pub(super) struct FeedHandler {
202 clock: &'static AtomicTime,
203 signal: Arc<AtomicBool>,
204 inner: Option<WebSocketClient>,
205 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
206 cmd_tx: Option<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>,
207 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
208 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
209 subscriptions: SubscriptionState,
210 retry_manager: RetryManager<LighterWsError>,
211 pending_messages: VecDeque<NautilusWsMessage>,
212 pending_subs: VecDeque<(LighterWsChannel, Option<String>)>,
213 inflight_subs: AHashSet<Ustr>,
214 instruments: AHashMap<i16, InstrumentAny>,
215 book_delta_subs: AHashSet<i16>,
216 book_depth_10_subs: AHashSet<i16>,
217 book_snapshots_seen: AHashSet<i16>,
218 book_states: AHashMap<i16, CachedOrderBook>,
219 last_candles: AHashMap<(i16, LighterCandleResolution), LighterWsCandle>,
220 exec_account: Option<(AccountId, i64)>,
221 account_state_reconciler: LighterAccountStateReconciler,
222}
223
224#[derive(Debug, Clone)]
225struct CachedOrderBook {
226 book: LighterWsOrderBook,
227 timestamp: u64,
228}
229
230impl FeedHandler {
231 pub(super) fn new(
232 signal: Arc<AtomicBool>,
233 cmd_rx: tokio::sync::mpsc::UnboundedReceiver<HandlerCommand>,
234 raw_rx: tokio::sync::mpsc::UnboundedReceiver<Message>,
235 out_tx: tokio::sync::mpsc::UnboundedSender<NautilusWsMessage>,
236 subscriptions: SubscriptionState,
237 ) -> Self {
238 Self {
239 clock: get_atomic_clock_realtime(),
240 signal,
241 inner: None,
242 cmd_rx,
243 cmd_tx: None,
244 raw_rx,
245 out_tx,
246 subscriptions,
247 retry_manager: create_websocket_retry_manager(),
248 pending_messages: VecDeque::new(),
249 pending_subs: VecDeque::new(),
250 inflight_subs: AHashSet::new(),
251 instruments: AHashMap::new(),
252 book_delta_subs: AHashSet::new(),
253 book_depth_10_subs: AHashSet::new(),
254 book_snapshots_seen: AHashSet::new(),
255 book_states: AHashMap::new(),
256 last_candles: AHashMap::new(),
257 exec_account: None,
258 account_state_reconciler: LighterAccountStateReconciler::new(),
259 }
260 }
261
262 pub(super) fn send(&self, msg: NautilusWsMessage) -> Result<(), String> {
263 self.out_tx
264 .send(msg)
265 .map_err(|e| format!("Failed to send message: {e}"))
266 }
267
268 pub(super) fn set_command_sender(
269 &mut self,
270 cmd_tx: tokio::sync::mpsc::UnboundedSender<HandlerCommand>,
271 ) {
272 self.cmd_tx = Some(cmd_tx);
273 }
274
275 pub(super) fn is_stopped(&self) -> bool {
276 self.signal.load(Ordering::Relaxed)
277 }
278
279 async fn send_with_retry(&self, payload: String) -> Result<(), LighterWsError> {
280 if let Some(client) = &self.inner {
281 self.retry_manager
282 .execute_with_retry(
283 "websocket_send",
284 || {
285 let payload = payload.clone();
286 async move {
287 client
288 .send_text(
289 payload,
290 Some(LIGHTER_WS_MESSAGE_RATE_LIMIT_KEY.as_slice()),
291 )
292 .await
293 .map_err(LighterWsError::Transport)
294 }
295 },
296 should_retry_lighter_ws_error,
297 create_lighter_ws_timeout_error,
298 )
299 .await
300 } else {
301 Err(LighterWsError::Client(
302 "no active WebSocket client".to_string(),
303 ))
304 }
305 }
306
307 async fn send_once(&self, payload: String) -> Result<(), LighterWsError> {
310 if let Some(client) = &self.inner {
311 client
312 .send_text(payload, None)
313 .await
314 .map_err(LighterWsError::Transport)
315 } else {
316 Err(LighterWsError::Client(
317 "no active WebSocket client".to_string(),
318 ))
319 }
320 }
321
322 async fn dispatch_subscribe(&self, channel: LighterWsChannel, auth: Option<String>) -> bool {
324 let topic = channel.topic_key();
325 self.subscriptions.mark_subscribe(&topic);
326
327 let authed = auth.is_some();
328 let request = match auth {
329 Some(token) => LighterWsRequest::subscribe_auth(channel.subscription_channel(), token),
330 None => LighterWsRequest::subscribe(channel.subscription_channel()),
331 };
332
333 match serde_json::to_string(&request) {
334 Ok(payload) => {
335 log::debug!("Sending Lighter subscribe: topic={topic} authed={authed}");
338 if let Err(e) = self.send_with_retry(payload).await {
339 log::error!("Error subscribing to {topic}: {e}");
340 self.subscriptions.mark_failure(&topic);
341 false
342 } else {
343 true
344 }
345 }
346 Err(e) => {
347 log::error!("Error serializing subscription for {topic}: {e}");
348 self.subscriptions.mark_failure(&topic);
349 false
350 }
351 }
352 }
353
354 async fn dispatch_send_tx(
355 &self,
356 tx_type: u8,
357 tx_info: Box<serde_json::value::RawValue>,
358 ) -> Result<(), LighterWsError> {
359 let request = LighterWsRequest::SendTx {
360 data: super::messages::LighterWsSendTx { tx_type, tx_info },
361 };
362
363 match serde_json::to_string(&request) {
364 Ok(payload) => {
365 log::debug!(
366 "Sending Lighter sendTx: tx_type={tx_type} ({} bytes)",
367 payload.len(),
368 );
369 log::debug!("Lighter sendTx payload: {payload}");
370 if let Err(e) = self.send_once(payload).await {
371 log::error!("Error dispatching Lighter sendTx (tx_type={tx_type}): {e}");
372 Err(e)
373 } else {
374 Ok(())
375 }
376 }
377 Err(e) => {
378 log::error!("Error serializing Lighter sendTx (tx_type={tx_type}): {e}");
379 Err(LighterWsError::Client(format!(
380 "failed to serialize Lighter sendTx: {e}"
381 )))
382 }
383 }
384 }
385
386 async fn dispatch_unsubscribe(&self, channel: LighterWsChannel) {
387 let topic = channel.topic_key();
388 self.subscriptions.mark_unsubscribe(&topic);
389
390 let request = LighterWsRequest::unsubscribe(channel.subscription_channel());
391 match serde_json::to_string(&request) {
392 Ok(payload) => {
393 log::debug!("Sending Lighter unsubscribe payload: {payload}");
394 if let Err(e) = self.send_with_retry(payload).await {
395 log::error!("Error unsubscribing from {topic}: {e}");
396 }
397 }
398 Err(e) => {
399 log::error!("Error serializing unsubscription for {topic}: {e}");
400 }
401 }
402 }
403
404 pub(super) async fn next(&mut self) -> Option<NautilusWsMessage> {
405 if let Some(msg) = self.pending_messages.pop_front() {
406 return Some(msg);
407 }
408
409 loop {
410 self.pump_pending_subscribes().await;
412
413 tokio::select! {
414 Some(cmd) = self.cmd_rx.recv() => {
415 match cmd {
416 HandlerCommand::SetClient(client) => {
417 log::debug!("Setting WebSocket client in Lighter handler");
418 self.inner = Some(client);
419 }
420 HandlerCommand::Disconnect => {
421 log::debug!("Lighter handler received disconnect");
422 if let Some(ref client) = self.inner {
423 client.disconnect().await;
424 }
425 self.signal.store(true, Ordering::SeqCst);
426 return None;
427 }
428 HandlerCommand::Subscribe { channel, auth } => {
429 self.pending_subs.push_back((channel, auth));
430 }
431 HandlerCommand::Unsubscribe { channel } => {
432 let topic = channel.topic_key();
435 self.pending_subs.retain(|(queued, _)| queued.topic_key() != topic);
436
437 if let LighterWsChannel::OrderBook(market_index) = &channel {
438 self.book_snapshots_seen.remove(market_index);
439 self.book_states.remove(market_index);
440 }
441 self.dispatch_unsubscribe(channel).await;
442 }
443 HandlerCommand::ResubscribeOrderBook { market_index } => {
444 self.resubscribe_order_book_stream(market_index).await;
445 }
446 HandlerCommand::InitializeInstruments(instruments) => {
447 self.instruments.clear();
448 for (market_index, inst) in instruments {
449 self.instruments.insert(market_index, inst);
450 }
451 }
452 HandlerCommand::UpdateInstrument { market_index, instrument } => {
453 self.instruments.insert(market_index, instrument);
454 }
455 HandlerCommand::SetBookDeltasSub { market_index, subscribed } => {
456 if subscribed {
457 let inserted = self.book_delta_subs.insert(market_index);
458 if inserted
459 && let Some(first) = self
460 .emit_cached_order_book_deltas_snapshot(market_index)
461 {
462 return Some(first);
463 }
464 } else {
465 self.book_delta_subs.remove(&market_index);
466 }
467 }
468 HandlerCommand::SetDepth10Sub { market_index, subscribed } => {
469 if subscribed {
470 let inserted = self.book_depth_10_subs.insert(market_index);
471 if inserted
472 && let Some(first) =
473 self.emit_cached_order_book_depth10_snapshot(market_index)
474 {
475 return Some(first);
476 }
477 } else {
478 self.book_depth_10_subs.remove(&market_index);
479 }
480 }
481 HandlerCommand::SetExecutionContext { account_id, account_index } => {
482 self.exec_account = Some((account_id, account_index));
483 }
484 HandlerCommand::SendTx {
485 tx_type,
486 tx_info,
487 response_tx,
488 } => {
489 let result = self.dispatch_send_tx(tx_type, tx_info).await;
490 if response_tx.send(result).is_err() {
491 log::debug!("Lighter sendTx result receiver dropped");
492 }
493 }
494 }
495 }
496 Some(raw_msg) = self.raw_rx.recv() => {
497 match raw_msg {
498 Message::Text(text) => {
499 if text == RECONNECTED {
500 log::debug!("Received Lighter WebSocket RECONNECTED sentinel");
501 self.book_snapshots_seen.clear();
502 self.book_states.clear();
503 self.last_candles.clear();
505 self.pending_subs.clear();
508 self.inflight_subs.clear();
509 self.account_state_reconciler.reset();
510 return Some(NautilusWsMessage::Reconnected);
511 }
512
513 let ts_init = self.clock.get_time_ns();
514
515 if let Ok(frame) = serde_json::from_str::<LighterWsFrame>(&text) {
516 let messages = self.handle_frame(frame, ts_init);
517 if let Some(first) = self.dispatch_results(messages) {
518 return Some(first);
519 }
520 } else {
521 let (matched, msg) = self.handle_control_text(&text);
522 if let Some(first) = msg {
523 return Some(first);
524 }
525
526 if !matched {
527 if let Ok(value) =
532 serde_json::from_str::<serde_json::Value>(&text)
533 {
534 log::warn!("Lighter WS unparsed frame: {value}");
535 return Some(NautilusWsMessage::Raw(value));
536 }
537 log::warn!("Lighter WS non-JSON text: {text}");
538 }
539 }
540 }
541 Message::Ping(data) => {
542 if let Some(ref client) = self.inner
543 && let Err(e) = client.send_pong(data.to_vec()).await {
544 log::error!("Error sending Lighter pong: {e}");
545 }
546 }
547 Message::Close(frame) => {
548 log::debug!("Received Lighter WebSocket close frame: {frame:?}");
549 return None;
550 }
551 _ => {}
552 }
553 }
554 else => {
555 log::debug!("Lighter handler shutting down: stream ended or command channel closed");
556 return None;
557 }
558 }
559 }
560 }
561
562 fn dispatch_results(
563 &mut self,
564 mut messages: Vec<NautilusWsMessage>,
565 ) -> Option<NautilusWsMessage> {
566 if messages.is_empty() {
567 return None;
568 }
569 let first = messages.remove(0);
570 for extra in messages {
571 self.pending_messages.push_back(extra);
572 }
573 Some(first)
574 }
575
576 async fn pump_pending_subscribes(&mut self) {
579 while self.inflight_subs.len() < SUBSCRIBE_INFLIGHT_MAX {
580 let Some((channel, auth)) = self.pending_subs.pop_front() else {
581 break;
582 };
583 let topic = Ustr::from(channel.topic_key().as_str());
584 self.inflight_subs.insert(topic);
585 if !self.dispatch_subscribe(channel, auth).await {
586 self.inflight_subs.remove(&topic);
587 }
588 }
589 }
590
591 fn release_subscribe_inflight(&mut self, topic: &str) -> bool {
594 self.inflight_subs.remove(&Ustr::from(topic))
595 }
596
597 fn handle_control_text(&mut self, text: &str) -> (bool, Option<NautilusWsMessage>) {
602 let Ok(value) = serde_json::from_str::<serde_json::Value>(text) else {
603 return (false, None);
604 };
605 let kind = value.get("type").and_then(|v| v.as_str()).unwrap_or("");
606
607 match kind {
608 CTRL_TYPE_CONNECTED => {
609 log::debug!("Lighter WebSocket handshake complete");
610 (true, None)
611 }
612 CTRL_TYPE_PING | CTRL_TYPE_PONG => (true, None),
613 CTRL_TYPE_SEND_TX => {
614 let raw_code = value.get("code").and_then(|v| v.as_u64());
615 match raw_code {
616 Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) => {
617 log_integrator_not_approved();
618 (
619 true,
620 Some(send_tx_rejected_from_value(
621 &value,
622 SendTxRejectionSource::Ack,
623 )),
624 )
625 }
626 Some(code) if code != 200 => {
627 log::error!("Lighter sendTx rejected: {value}");
628 (
629 true,
630 Some(send_tx_rejected_from_value(
631 &value,
632 SendTxRejectionSource::Ack,
633 )),
634 )
635 }
636 _ => {
637 log::debug!("Lighter WebSocket sendTx ack: {value}");
638 let tx_hash = value
639 .get("tx_hash")
640 .and_then(|v| v.as_str())
641 .map(str::to_string);
642 (
643 true,
644 Some(NautilusWsMessage::SendTxAck { tx_hash, code: 200 }),
645 )
646 }
647 }
648 }
649 CTRL_TYPE_SUBSCRIBED | CTRL_TYPE_UNSUBSCRIBED => {
650 if let Some(topic) = value.get("channel").and_then(|v| v.as_str()) {
651 if kind == CTRL_TYPE_SUBSCRIBED {
652 self.subscriptions.confirm_subscribe(topic);
653 self.release_subscribe_inflight(topic);
654 } else {
655 let was_pending_unsubscribe = self
656 .subscriptions
657 .pending_unsubscribe_topics()
658 .iter()
659 .any(|pending| pending == topic);
660 self.subscriptions.confirm_unsubscribe(topic);
661
662 if was_pending_unsubscribe {
663 if let Some(market_index) = order_book_market_index_from_topic(topic) {
665 self.clear_cached_order_book(market_index);
666 }
667
668 if let Some(key) = candle_market_and_resolution_from_topic(topic) {
669 self.last_candles.remove(&key);
670 }
671 }
672 }
673 }
674 (true, None)
675 }
676 CTRL_TYPE_ERROR => {
677 let code = value.get("code").and_then(|v| v.as_u64());
678 if code == Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) {
679 log_integrator_not_approved();
680 } else {
681 log::warn!("Lighter WebSocket error frame: {value}");
682 }
683
684 if is_sendtx_error_code(code) {
685 (
686 true,
687 Some(send_tx_rejected_from_value(
688 &value,
689 SendTxRejectionSource::BareError,
690 )),
691 )
692 } else {
693 (true, None)
694 }
695 }
696 _ => {
697 if let Some(error) = value.get("error") {
698 let nested_code = error.get("code").and_then(|v| v.as_u64());
699 if nested_code == Some(LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED) {
700 log_integrator_not_approved();
701 } else {
702 log::warn!("Lighter WebSocket error frame: {value}");
703 }
704 let rejected = is_sendtx_error_code(nested_code).then(|| {
705 send_tx_rejected_from_nested_error(error, SendTxRejectionSource::BareError)
706 });
707 return (true, rejected);
708 }
709 (false, None)
710 }
711 }
712 }
713
714 fn handle_frame(
715 &mut self,
716 frame: LighterWsFrame,
717 ts_init: UnixNanos,
718 ) -> Vec<NautilusWsMessage> {
719 let topic = frame_topic(&frame);
720 self.subscriptions.confirm_subscribe(&topic);
721 if !self.inflight_subs.is_empty() {
722 self.release_subscribe_inflight(&topic);
723 }
724
725 match frame {
726 LighterWsFrame::OrderBookSnapshot {
727 channel,
728 order_book,
729 timestamp,
730 ..
731 } => self.handle_order_book(channel, &order_book, timestamp, true, ts_init),
732 LighterWsFrame::OrderBook {
733 channel,
734 order_book,
735 timestamp,
736 ..
737 } => self.handle_order_book(channel, &order_book, timestamp, false, ts_init),
738 LighterWsFrame::TickerSnapshot {
739 channel,
740 ticker,
741 timestamp,
742 ..
743 }
744 | LighterWsFrame::Ticker {
745 channel,
746 ticker,
747 timestamp,
748 ..
749 } => self.handle_ticker(channel, &ticker, timestamp, ts_init),
750 LighterWsFrame::TradeSnapshot {
751 trades,
752 liquidation_trades,
753 ..
754 }
755 | LighterWsFrame::Trade {
756 trades,
757 liquidation_trades,
758 ..
759 } => self.handle_trades(&trades, &liquidation_trades, ts_init),
760 LighterWsFrame::AccountOrders { ref orders, .. }
761 | LighterWsFrame::AccountAllOrders { ref orders, .. } => {
762 if self.exec_account.is_none() {
763 return raw_message(&frame);
764 }
765 let mut msgs = self.handle_account_orders(orders, ts_init);
766 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
767 AccountStream::Orders,
768 ));
769 msgs
770 }
771 LighterWsFrame::AccountAllTradesSnapshot { .. } => {
779 if self.exec_account.is_none() {
780 return raw_message(&frame);
781 }
782 log::debug!(
783 "Skipping Lighter account_all_trades snapshot frame; \
784 reconcile historical fills via HTTP",
785 );
786 vec![NautilusWsMessage::AccountStreamFirstFrame(
787 AccountStream::Trades,
788 )]
789 }
790 LighterWsFrame::AccountAllTrades { ref trades, .. } => {
791 if self.exec_account.is_none() {
792 return raw_message(&frame);
793 }
794 let flat: Vec<LighterTrade> = trades.values().flatten().cloned().collect();
795 let mut msgs = self.handle_account_trades(&flat, ts_init);
796 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
797 AccountStream::Trades,
798 ));
799 msgs
800 }
801 LighterWsFrame::AccountAllPositions { ref positions, .. } => {
802 if self.exec_account.is_none() {
803 return raw_message(&frame);
804 }
805 let mut msgs = self.handle_account_positions(positions, ts_init);
806 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
807 AccountStream::Positions,
808 ));
809 msgs
810 }
811 LighterWsFrame::AccountAllAssets {
812 ref assets,
813 timestamp,
814 ..
815 } => {
816 if self.exec_account.is_none() {
817 return raw_message(&frame);
818 }
819 let mut msgs = self.handle_account_assets(assets, timestamp, ts_init);
820 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
821 AccountStream::Assets,
822 ));
823 msgs
824 }
825 LighterWsFrame::UserStats {
826 ref stats,
827 timestamp,
828 ..
829 } => {
830 if self.exec_account.is_none() {
831 return raw_message(&frame);
832 }
833 let mut msgs = self.handle_user_stats(stats, timestamp, ts_init);
834 msgs.push(NautilusWsMessage::AccountStreamFirstFrame(
835 AccountStream::UserStats,
836 ));
837 msgs
838 }
839 LighterWsFrame::MarketStats {
840 ref market_stats,
841 timestamp,
842 ..
843 } => self.handle_market_stats(market_stats, timestamp, ts_init),
844 LighterWsFrame::SpotMarketStats {
845 ref spot_market_stats,
846 timestamp,
847 ..
848 } => self.handle_spot_market_stats(spot_market_stats, timestamp, ts_init),
849 LighterWsFrame::CandleSnapshot {
850 channel,
851 ref candles,
852 ..
853 }
854 | LighterWsFrame::Candle {
855 channel,
856 ref candles,
857 ..
858 } => self.handle_candles(channel, candles, ts_init),
859 LighterWsFrame::Height { .. } => raw_message(&frame),
860 }
861 }
862
863 fn handle_order_book(
864 &mut self,
865 channel: Ustr,
866 book: &super::messages::LighterWsOrderBook,
867 timestamp: u64,
868 is_snapshot: bool,
869 ts_init: UnixNanos,
870 ) -> Vec<NautilusWsMessage> {
871 let market_index = match market_index_from_topic(channel.as_str()) {
872 Some(index) => index,
873 None => {
874 log::debug!("Lighter order_book frame missing market index in channel '{channel}'");
875 return Vec::new();
876 }
877 };
878
879 if !self.instruments.contains_key(&market_index) {
880 log::debug!("No instrument cached for Lighter market_index={market_index}");
881 return Vec::new();
882 }
883
884 if !self.book_delta_subs.contains(&market_index)
885 && !self.book_depth_10_subs.contains(&market_index)
886 {
887 return Vec::new();
888 }
889
890 if !is_snapshot && !self.book_snapshots_seen.contains(&market_index) {
894 log::warn!(
895 "Dropping Lighter order_book update before snapshot for market_index={market_index}",
896 );
897 return Vec::new();
898 }
899
900 if is_snapshot {
901 self.book_snapshots_seen.insert(market_index);
902 self.book_states.insert(
903 market_index,
904 CachedOrderBook {
905 book: book.clone(),
906 timestamp,
907 },
908 );
909 } else if let Some(cached_nonce) = self
910 .book_states
911 .get(&market_index)
912 .map(|state| state.book.nonce)
913 {
914 if book.begin_nonce != cached_nonce {
915 log::warn!(
916 "Dropping Lighter order_book update with nonce gap for \
917 market_index={market_index}: begin_nonce={}, cached_nonce={cached_nonce}",
918 book.begin_nonce,
919 );
920 self.clear_cached_order_book(market_index);
921 self.queue_order_book_resync(market_index);
922 return Vec::new();
923 }
924
925 if let Some(state) = self.book_states.get_mut(&market_index) {
926 apply_order_book_update(&mut state.book, book);
927 state.timestamp = timestamp;
928 }
929 } else {
930 log::warn!(
931 "Dropping Lighter order_book update without cached state for \
932 market_index={market_index}",
933 );
934 self.clear_cached_order_book(market_index);
935 self.queue_order_book_resync(market_index);
936 return Vec::new();
937 }
938
939 self.order_book_messages(market_index, book, timestamp, is_snapshot, ts_init)
940 }
941
942 fn clear_cached_order_book(&mut self, market_index: i16) {
943 self.book_snapshots_seen.remove(&market_index);
944 self.book_states.remove(&market_index);
945 }
946
947 fn queue_order_book_resync(&self, market_index: i16) {
948 if !self.order_book_stream_is_referenced(market_index) {
949 log::debug!(
950 "Skipping Lighter order_book resync: subscription cancelled, \
951 market_index={market_index}",
952 );
953 return;
954 }
955
956 let Some(cmd_tx) = &self.cmd_tx else {
957 log::error!(
958 "Cannot resync Lighter order_book stream without command sender: \
959 market_index={market_index}",
960 );
961 return;
962 };
963
964 if let Err(e) = cmd_tx.send(HandlerCommand::ResubscribeOrderBook { market_index }) {
965 log::error!("Failed to queue Lighter order_book resync: {e}");
966 }
967 }
968
969 async fn resubscribe_order_book_stream(&self, market_index: i16) {
970 if !self.order_book_stream_is_referenced(market_index) {
971 log::debug!(
972 "Skipping Lighter order_book resync: subscription cancelled before venue \
973 unsubscribe, market_index={market_index}",
974 );
975 return;
976 }
977
978 let channel = LighterWsChannel::OrderBook(market_index);
979 self.dispatch_unsubscribe(channel.clone()).await;
980
981 if !self.order_book_stream_is_referenced(market_index) {
982 log::debug!(
983 "Skipping Lighter order_book resubscribe: subscription cancelled after venue \
984 unsubscribe, market_index={market_index}",
985 );
986 return;
987 }
988
989 self.dispatch_subscribe(channel.clone(), None).await;
990
991 if !self.order_book_stream_is_referenced(market_index) {
992 log::debug!(
993 "Cancelling Lighter order_book resync subscribe after user unsubscribe: \
994 market_index={market_index}",
995 );
996 self.dispatch_unsubscribe(channel).await;
997 }
998 }
999
1000 fn order_book_stream_is_referenced(&self, market_index: i16) -> bool {
1001 let channel = LighterWsChannel::OrderBook(market_index);
1002 self.subscriptions.get_reference_count(&channel.topic_key()) > 0
1003 && (self.book_delta_subs.contains(&market_index)
1004 || self.book_depth_10_subs.contains(&market_index))
1005 }
1006
1007 fn emit_cached_order_book_deltas_snapshot(
1008 &self,
1009 market_index: i16,
1010 ) -> Option<NautilusWsMessage> {
1011 let cached = self.book_states.get(&market_index)?.clone();
1012 let instrument = self.instruments.get(&market_index)?;
1013 let ts_init = self.clock.get_time_ns();
1014 match parse_ws_order_book_deltas(&cached.book, instrument, cached.timestamp, true, ts_init)
1015 {
1016 Ok(deltas) => Some(NautilusWsMessage::Deltas(deltas)),
1017 Err(e) => {
1018 log::error!("Error parsing cached Lighter order_book deltas: {e}");
1019 None
1020 }
1021 }
1022 }
1023
1024 fn emit_cached_order_book_depth10_snapshot(
1025 &self,
1026 market_index: i16,
1027 ) -> Option<NautilusWsMessage> {
1028 let cached = self.book_states.get(&market_index)?.clone();
1029 let instrument = self.instruments.get(&market_index)?;
1030 let ts_init = self.clock.get_time_ns();
1031 match parse_ws_order_book_depth10(&cached.book, instrument, cached.timestamp, ts_init) {
1032 Ok(depth) => Some(NautilusWsMessage::Depth10(Box::new(depth))),
1033 Err(e) => {
1034 log::error!("Error parsing cached Lighter order_book depth10: {e}");
1035 None
1036 }
1037 }
1038 }
1039
1040 fn order_book_messages(
1041 &self,
1042 market_index: i16,
1043 book: &LighterWsOrderBook,
1044 timestamp: u64,
1045 is_snapshot: bool,
1046 ts_init: UnixNanos,
1047 ) -> Vec<NautilusWsMessage> {
1048 let Some(instrument) = self.instruments.get(&market_index) else {
1049 log::debug!("No instrument cached for Lighter market_index={market_index}");
1050 return Vec::new();
1051 };
1052
1053 let mut messages = Vec::new();
1054
1055 if self.book_delta_subs.contains(&market_index) {
1056 match parse_ws_order_book_deltas(book, instrument, timestamp, is_snapshot, ts_init) {
1057 Ok(deltas) => messages.push(NautilusWsMessage::Deltas(deltas)),
1058 Err(e) => log::error!("Error parsing Lighter order_book deltas: {e}"),
1059 }
1060 }
1061
1062 if self.book_depth_10_subs.contains(&market_index)
1063 && let Some(cached) = self.book_states.get(&market_index)
1064 {
1065 match parse_ws_order_book_depth10(&cached.book, instrument, cached.timestamp, ts_init) {
1066 Ok(depth) => messages.push(NautilusWsMessage::Depth10(Box::new(depth))),
1067 Err(e) => log::error!("Error parsing Lighter order_book depth10: {e}"),
1068 }
1069 }
1070
1071 messages
1072 }
1073
1074 fn handle_ticker(
1075 &self,
1076 channel: Ustr,
1077 ticker: &super::messages::LighterTicker,
1078 timestamp: u64,
1079 ts_init: UnixNanos,
1080 ) -> Vec<NautilusWsMessage> {
1081 let Some(market_index) = market_index_from_topic(channel.as_str()) else {
1087 log::debug!("Lighter ticker frame missing market index in channel '{channel}'");
1088 return Vec::new();
1089 };
1090
1091 let Some(instrument) = self.instruments.get(&market_index) else {
1092 log::debug!("No instrument cached for Lighter ticker market_index={market_index}");
1093 return Vec::new();
1094 };
1095
1096 match parse_ws_quote_tick(ticker, instrument, timestamp, ts_init) {
1097 Ok(Some(quote)) => vec![NautilusWsMessage::Quote(quote)],
1098 Ok(None) => {
1099 log::debug!(
1100 "Skipping Lighter ticker for market_index={market_index}: one-sided book",
1101 );
1102 Vec::new()
1103 }
1104 Err(e) => {
1105 log::error!("Error parsing Lighter ticker frame: {e}");
1106 Vec::new()
1107 }
1108 }
1109 }
1110
1111 fn handle_trades(
1112 &self,
1113 trades: &[crate::http::models::LighterTrade],
1114 liquidation_trades: &[crate::http::models::LighterTrade],
1115 ts_init: UnixNanos,
1116 ) -> Vec<NautilusWsMessage> {
1117 let Some(market_index) = trades
1121 .first()
1122 .or_else(|| liquidation_trades.first())
1123 .map(|t| t.market_id)
1124 else {
1125 return Vec::new();
1126 };
1127
1128 let Some(instrument) = self.instruments.get(&market_index) else {
1129 log::debug!("No instrument cached for Lighter trade market_index={market_index}");
1130 return Vec::new();
1131 };
1132
1133 let mut ticks = Vec::with_capacity(trades.len() + liquidation_trades.len());
1134 for trade in trades.iter().chain(liquidation_trades.iter()) {
1135 match parse_ws_trade_tick(trade, instrument, ts_init) {
1136 Ok(tick) => ticks.push(tick),
1137 Err(e) => log::error!("Error parsing Lighter trade tick: {e}"),
1138 }
1139 }
1140
1141 if ticks.is_empty() {
1142 Vec::new()
1143 } else {
1144 vec![NautilusWsMessage::Trades(ticks)]
1145 }
1146 }
1147
1148 fn handle_market_stats(
1149 &self,
1150 payload: &super::messages::LighterMarketStatsPayload,
1151 timestamp: u64,
1152 ts_init: UnixNanos,
1153 ) -> Vec<NautilusWsMessage> {
1154 match payload {
1155 super::messages::LighterMarketStatsPayload::All(stats) => stats
1156 .values()
1157 .flat_map(|stats| self.handle_one_market_stats(stats, timestamp, ts_init))
1158 .collect(),
1159 super::messages::LighterMarketStatsPayload::One(stats) => {
1160 self.handle_one_market_stats(stats, timestamp, ts_init)
1161 }
1162 }
1163 }
1164
1165 fn handle_one_market_stats(
1166 &self,
1167 stats: &super::messages::LighterMarketStats,
1168 timestamp: u64,
1169 ts_init: UnixNanos,
1170 ) -> Vec<NautilusWsMessage> {
1171 let Some(instrument) = self.instruments.get(&stats.market_id) else {
1172 log::debug!(
1173 "No instrument cached for Lighter market_stats market_id={}",
1174 stats.market_id,
1175 );
1176 return Vec::new();
1177 };
1178
1179 let mut messages = Vec::with_capacity(3);
1180
1181 match parse_ws_mark_price_update(stats, instrument, timestamp, ts_init) {
1182 Ok(mark_price) => messages.push(NautilusWsMessage::MarkPrice(mark_price)),
1183 Err(e) => log::error!("Error parsing Lighter mark price: {e}"),
1184 }
1185
1186 match parse_ws_index_price_update(stats, instrument, timestamp, ts_init) {
1187 Ok(index_price) => messages.push(NautilusWsMessage::IndexPrice(index_price)),
1188 Err(e) => log::error!("Error parsing Lighter index price: {e}"),
1189 }
1190
1191 match parse_ws_funding_rate_update(stats, instrument, timestamp, ts_init) {
1192 Ok(funding_rate) => messages.push(NautilusWsMessage::FundingRate(funding_rate)),
1193 Err(e) => log::error!("Error parsing Lighter funding rate: {e}"),
1194 }
1195
1196 messages
1197 }
1198
1199 fn handle_spot_market_stats(
1200 &self,
1201 payload: &super::messages::LighterSpotMarketStatsPayload,
1202 timestamp: u64,
1203 ts_init: UnixNanos,
1204 ) -> Vec<NautilusWsMessage> {
1205 match payload {
1206 super::messages::LighterSpotMarketStatsPayload::All(stats) => stats
1207 .values()
1208 .filter_map(|stats| self.handle_one_spot_market_stats(stats, timestamp, ts_init))
1209 .collect(),
1210 super::messages::LighterSpotMarketStatsPayload::One(stats) => self
1211 .handle_one_spot_market_stats(stats, timestamp, ts_init)
1212 .into_iter()
1213 .collect(),
1214 }
1215 }
1216
1217 fn handle_one_spot_market_stats(
1218 &self,
1219 stats: &super::messages::LighterSpotMarketStats,
1220 timestamp: u64,
1221 ts_init: UnixNanos,
1222 ) -> Option<NautilusWsMessage> {
1223 let Some(instrument) = self.instruments.get(&stats.market_id) else {
1224 log::debug!(
1225 "No instrument cached for Lighter spot_market_stats market_id={}",
1226 stats.market_id,
1227 );
1228 return None;
1229 };
1230
1231 match parse_ws_spot_index_price_update(stats, instrument, timestamp, ts_init) {
1232 Ok(index_price) => Some(NautilusWsMessage::IndexPrice(index_price)),
1233 Err(e) => {
1234 log::error!("Error parsing Lighter spot index price: {e}");
1235 None
1236 }
1237 }
1238 }
1239
1240 fn handle_candles(
1241 &mut self,
1242 channel: Ustr,
1243 candles: &[LighterWsCandle],
1244 ts_init: UnixNanos,
1245 ) -> Vec<NautilusWsMessage> {
1246 let Some((market_index, resolution)) =
1247 candle_market_and_resolution_from_topic(channel.as_str())
1248 else {
1249 log::warn!("Lighter candle frame with unparsable channel `{channel}`");
1250 return Vec::new();
1251 };
1252
1253 let Some(instrument) = self.instruments.get(&market_index) else {
1254 log::debug!("No instrument cached for Lighter candle market_index={market_index}");
1255 return Vec::new();
1256 };
1257
1258 let key = (market_index, resolution);
1259 let mut emitted = Vec::new();
1260
1261 for candle in candles {
1262 let previous = self.last_candles.get(&key).cloned();
1263 match previous {
1264 None => {}
1265 Some(prev) if candle.t > prev.t => {
1266 match parse_ws_bar(instrument, &prev, resolution, ts_init) {
1268 Ok(bar) => emitted.push(NautilusWsMessage::Bar(bar)),
1269 Err(e) => log::error!("Error parsing Lighter candle bar: {e}"),
1270 }
1271 }
1272 Some(prev) if candle.t < prev.t => continue,
1273 Some(_) => {}
1274 }
1275 self.last_candles.insert(key, candle.clone());
1276 }
1277
1278 emitted
1279 }
1280
1281 fn handle_account_orders(
1282 &self,
1283 orders_by_market: &AHashMap<Ustr, Vec<LighterOrder>>,
1284 _ts_init: UnixNanos,
1285 ) -> Vec<NautilusWsMessage> {
1286 if self.exec_account.is_none() {
1287 log::debug!("Lighter account_orders frame skipped: no execution context set");
1288 return Vec::new();
1289 }
1290
1291 let mut reports = Vec::new();
1292
1293 for orders in orders_by_market.values() {
1294 for order in orders {
1295 if !self.instruments.contains_key(&order.market_index) {
1296 log::debug!(
1297 "No instrument cached for Lighter order market_index={}",
1298 order.market_index,
1299 );
1300 continue;
1301 }
1302
1303 reports.push(ExecutionReport::Order(order.clone()));
1304 }
1305 }
1306
1307 if reports.is_empty() {
1308 Vec::new()
1309 } else {
1310 vec![NautilusWsMessage::ExecutionReports(reports)]
1311 }
1312 }
1313
1314 fn handle_account_trades(
1315 &self,
1316 trades: &[LighterTrade],
1317 _ts_init: UnixNanos,
1318 ) -> Vec<NautilusWsMessage> {
1319 let Some((_account_id, account_index)) = self.exec_account else {
1320 log::debug!("Lighter account_trades frame skipped: no execution context set");
1321 return Vec::new();
1322 };
1323
1324 let mut reports = Vec::new();
1325
1326 for trade in trades {
1327 if !self.instruments.contains_key(&trade.market_id) {
1328 log::debug!(
1329 "No instrument cached for Lighter account trade market_id={}",
1330 trade.market_id,
1331 );
1332 continue;
1333 }
1334
1335 if trade.bid_account_id != account_index && trade.ask_account_id != account_index {
1339 continue;
1340 }
1341
1342 reports.push(ExecutionReport::Fill(trade.clone()));
1343 }
1344
1345 if reports.is_empty() {
1346 Vec::new()
1347 } else {
1348 vec![NautilusWsMessage::ExecutionReports(reports)]
1349 }
1350 }
1351
1352 fn handle_account_positions(
1353 &self,
1354 positions: &AHashMap<Ustr, LighterPosition>,
1355 ts_init: UnixNanos,
1356 ) -> Vec<NautilusWsMessage> {
1357 let Some((account_id, _)) = self.exec_account else {
1358 log::debug!("Lighter account_positions frame skipped: no execution context set");
1359 return Vec::new();
1360 };
1361
1362 let ts_event = ts_init;
1365
1366 let mut reports = Vec::new();
1367 let mut skipped_market_ids = Vec::new();
1368
1369 for position in positions.values() {
1370 let Some(instrument) = self.instruments.get(&position.market_id) else {
1371 log::debug!(
1372 "No instrument cached for Lighter position market_id={}",
1373 position.market_id,
1374 );
1375 skipped_market_ids.push(position.market_id);
1376 continue;
1377 };
1378
1379 match parse_ws_position_status_report(
1380 position, instrument, account_id, ts_event, ts_init,
1381 ) {
1382 Ok(report) => reports.push(report),
1383 Err(e) => {
1384 skipped_market_ids.push(position.market_id);
1385 log::error!("Error parsing Lighter position status report: {e}");
1386 }
1387 }
1388 }
1389
1390 vec![NautilusWsMessage::PositionSnapshot {
1392 reports,
1393 skipped_market_ids,
1394 }]
1395 }
1396
1397 fn handle_account_assets(
1398 &self,
1399 assets: &AHashMap<Ustr, LighterAsset>,
1400 timestamp_ms: u64,
1401 ts_init: UnixNanos,
1402 ) -> Vec<NautilusWsMessage> {
1403 let Some((account_id, _)) = self.exec_account else {
1404 log::debug!("Lighter account_assets frame skipped: no execution context set");
1405 return Vec::new();
1406 };
1407
1408 let ts_event = match crate::common::parse::parse_millis_to_nanos(timestamp_ms) {
1409 Ok(ts) => ts,
1410 Err(e) => {
1411 log::error!("Invalid Lighter account_assets timestamp {timestamp_ms}: {e}");
1412 return Vec::new();
1413 }
1414 };
1415
1416 self.account_state_reconciler.update_assets(assets);
1417 self.emit_unified_account_state(account_id, ts_event, ts_init)
1418 }
1419
1420 fn handle_user_stats(
1421 &self,
1422 stats: &LighterUserStats,
1423 timestamp_ms: u64,
1424 ts_init: UnixNanos,
1425 ) -> Vec<NautilusWsMessage> {
1426 let Some((account_id, _)) = self.exec_account else {
1427 log::debug!("Lighter user_stats frame skipped: no execution context set");
1428 return Vec::new();
1429 };
1430
1431 let ts_event = match crate::common::parse::parse_millis_to_nanos(timestamp_ms) {
1432 Ok(ts) => ts,
1433 Err(e) => {
1434 log::error!("Invalid Lighter user_stats timestamp {timestamp_ms}: {e}");
1435 return Vec::new();
1436 }
1437 };
1438
1439 self.account_state_reconciler.update_user_stats(stats);
1440 self.emit_unified_account_state(account_id, ts_event, ts_init)
1441 }
1442
1443 fn emit_unified_account_state(
1448 &self,
1449 account_id: AccountId,
1450 ts_event: UnixNanos,
1451 ts_init: UnixNanos,
1452 ) -> Vec<NautilusWsMessage> {
1453 match self
1454 .account_state_reconciler
1455 .build_state(account_id, ts_event, ts_init)
1456 {
1457 Some(Ok(state)) => vec![NautilusWsMessage::AccountState(Box::new(state))],
1458 Some(Err(e)) => {
1459 log::error!("Error building unified Lighter account state: {e}");
1460 Vec::new()
1461 }
1462 None => Vec::new(),
1463 }
1464 }
1465}
1466
1467fn raw_message(frame: &LighterWsFrame) -> Vec<NautilusWsMessage> {
1468 let value = serde_json::to_value(frame).unwrap_or(serde_json::Value::Null);
1469 vec![NautilusWsMessage::Raw(value)]
1470}
1471
1472fn apply_order_book_update(state: &mut LighterWsOrderBook, update: &LighterWsOrderBook) {
1473 apply_book_side_update(&mut state.bids, &update.bids, true);
1474 apply_book_side_update(&mut state.asks, &update.asks, false);
1475
1476 state.code = update.code;
1477 state.offset = update.offset;
1478 state.nonce = update.nonce;
1479 state.last_updated_at = update.last_updated_at;
1480 state.begin_nonce = update.begin_nonce;
1481}
1482
1483fn apply_book_side_update(
1484 levels: &mut Vec<LighterPriceLevel>,
1485 updates: &[LighterPriceLevel],
1486 bids: bool,
1487) {
1488 for update in updates {
1489 if update.price == Decimal::ZERO {
1490 continue;
1491 }
1492
1493 match find_book_level(levels, update.price, bids) {
1494 Ok(index) if update.size == Decimal::ZERO => {
1495 levels.remove(index);
1496 }
1497 Ok(index) => {
1498 levels[index] = update.clone();
1499 }
1500 Err(_) if update.size == Decimal::ZERO => {}
1501 Err(index) => {
1502 levels.insert(index, update.clone());
1503 }
1504 }
1505 }
1506}
1507
1508fn find_book_level(
1509 levels: &[LighterPriceLevel],
1510 price: Decimal,
1511 bids: bool,
1512) -> Result<usize, usize> {
1513 levels.binary_search_by(|level| {
1514 if bids {
1515 price.cmp(&level.price)
1516 } else {
1517 level.price.cmp(&price)
1518 }
1519 })
1520}
1521
1522fn is_sendtx_error_code(code: Option<u64>) -> bool {
1525 code.is_some_and(|c| LIGHTER_ERROR_CODE_TX_RANGE.contains(&c))
1526}
1527
1528fn send_tx_rejected_from_value(
1531 value: &serde_json::Value,
1532 source: SendTxRejectionSource,
1533) -> NautilusWsMessage {
1534 let code = value.get("code").and_then(|v| v.as_i64());
1535 let message = value
1536 .get("message")
1537 .and_then(|v| v.as_str())
1538 .unwrap_or("")
1539 .to_string();
1540 let tx_hash = value
1541 .get("tx_hash")
1542 .and_then(|v| v.as_str())
1543 .map(str::to_string);
1544 NautilusWsMessage::SendTxRejected {
1545 source,
1546 code,
1547 message,
1548 tx_hash,
1549 }
1550}
1551
1552fn send_tx_rejected_from_nested_error(
1555 error: &serde_json::Value,
1556 source: SendTxRejectionSource,
1557) -> NautilusWsMessage {
1558 let code = error.get("code").and_then(|v| v.as_i64());
1559 let message = error
1560 .get("message")
1561 .and_then(|v| v.as_str())
1562 .unwrap_or("")
1563 .to_string();
1564 NautilusWsMessage::SendTxRejected {
1565 source,
1566 code,
1567 message,
1568 tx_hash: None,
1569 }
1570}
1571
1572fn log_integrator_not_approved() {
1573 log::error!(
1574 "Lighter venue rejected with code {LIGHTER_ERROR_CODE_INTEGRATOR_NOT_APPROVED} \
1575 'integrator is not approved'.\n\
1576 Tagged orders require Nautilus integrator approval. \
1577 See: {LIGHTER_INTEGRATOR_APPROVAL_DOCS_URL}",
1578 );
1579}
1580
1581fn frame_topic(frame: &LighterWsFrame) -> String {
1582 match frame {
1583 LighterWsFrame::OrderBookSnapshot { channel, .. }
1584 | LighterWsFrame::OrderBook { channel, .. }
1585 | LighterWsFrame::TickerSnapshot { channel, .. }
1586 | LighterWsFrame::Ticker { channel, .. }
1587 | LighterWsFrame::MarketStats { channel, .. }
1588 | LighterWsFrame::SpotMarketStats { channel, .. }
1589 | LighterWsFrame::TradeSnapshot { channel, .. }
1590 | LighterWsFrame::Trade { channel, .. }
1591 | LighterWsFrame::AccountOrders { channel, .. }
1592 | LighterWsFrame::AccountAllOrders { channel, .. }
1593 | LighterWsFrame::AccountAllTradesSnapshot { channel, .. }
1594 | LighterWsFrame::AccountAllTrades { channel, .. }
1595 | LighterWsFrame::AccountAllPositions { channel, .. }
1596 | LighterWsFrame::AccountAllAssets { channel, .. }
1597 | LighterWsFrame::UserStats { channel, .. }
1598 | LighterWsFrame::Height { channel, .. }
1599 | LighterWsFrame::CandleSnapshot { channel, .. }
1600 | LighterWsFrame::Candle { channel, .. } => channel.as_str().to_string(),
1601 }
1602}
1603
1604fn market_index_from_topic(topic: &str) -> Option<i16> {
1605 let (_, rest) = topic.split_once(':')?;
1606 rest.parse::<i16>().ok()
1607}
1608
1609fn candle_market_and_resolution_from_topic(topic: &str) -> Option<(i16, LighterCandleResolution)> {
1610 let (channel, rest) = topic.split_once(':')?;
1611 if LighterWsChannelKind::from_wire_str(channel) != Some(LighterWsChannelKind::Candle) {
1612 return None;
1613 }
1614 let (market, res) = rest.split_once(':')?;
1615 let market_index = market.parse::<i16>().ok()?;
1616 let resolution = res.parse::<LighterCandleResolution>().ok()?;
1617 Some((market_index, resolution))
1618}
1619
1620fn order_book_market_index_from_topic(topic: &str) -> Option<i16> {
1621 let (channel, rest) = topic.split_once(':')?;
1622 if LighterWsChannelKind::from_wire_str(channel) != Some(LighterWsChannelKind::OrderBook) {
1623 return None;
1624 }
1625 rest.parse::<i16>().ok()
1626}
1627
1628pub(crate) fn should_retry_lighter_ws_error(error: &LighterWsError) -> bool {
1629 match error {
1630 LighterWsError::Network(_) => true,
1631 LighterWsError::Transport(send_error) => match send_error {
1634 SendError::Timeout => true,
1635 SendError::Closed | SendError::BrokenPipe(_) => false,
1636 },
1637 LighterWsError::Authentication(_)
1638 | LighterWsError::Parse(_)
1639 | LighterWsError::Client(_) => false,
1640 }
1641}
1642
1643pub(crate) fn create_lighter_ws_timeout_error(_msg: String) -> LighterWsError {
1644 LighterWsError::Transport(SendError::Timeout)
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 use std::time::Duration;
1652
1653 use nautilus_model::{
1654 enums::AccountType,
1655 identifiers::{InstrumentId, Symbol, Venue},
1656 instruments::{CryptoPerpetual, CurrencyPair},
1657 types::{Currency, Money, Price, Quantity},
1658 };
1659 use rstest::rstest;
1660 use rust_decimal::Decimal;
1661 use serde_json::json;
1662
1663 use super::*;
1664 use crate::{
1665 common::enums::{LighterCandleResolution, LighterTxType},
1666 websocket::messages::{LighterMarketSelection, LighterWsCandle, LighterWsChannel},
1667 };
1668
1669 const WS_ACCOUNT_ORDERS_UPDATE: &str =
1670 include_str!("../../test_data/ws_account_orders_update.json");
1671 const WS_ACCOUNT_ALL_TRADES_UPDATE: &str =
1672 include_str!("../../test_data/ws_account_all_trades_update.json");
1673 const WS_ACCOUNT_ALL_POSITIONS_UPDATE: &str =
1674 include_str!("../../test_data/ws_account_all_positions_update.json");
1675 const WS_ACCOUNT_ALL_ASSETS_UPDATE: &str =
1676 include_str!("../../test_data/ws_account_all_assets_update.json");
1677 const WS_USER_STATS_UPDATE: &str = include_str!("../../test_data/ws_user_stats_update.json");
1678 const WS_ACCOUNT_ALL_ASSETS_WITH_POSITION: &str =
1679 include_str!("../../test_data/ws_account_all_assets_with_position.json");
1680 const WS_USER_STATS_WITH_POSITION: &str =
1681 include_str!("../../test_data/ws_user_stats_with_position.json");
1682 const WS_MARKET_STATS_UPDATE_SINGLE: &str =
1683 include_str!("../../test_data/ws_market_stats_update_single.json");
1684 const WS_MARKET_STATS_UPDATE_ALL: &str =
1685 include_str!("../../test_data/ws_market_stats_update_all.json");
1686 const WS_SPOT_MARKET_STATS_UPDATE_SINGLE: &str =
1687 include_str!("../../test_data/ws_spot_market_stats_update_single.json");
1688 const WS_SPOT_MARKET_STATS_UPDATE_ALL: &str =
1689 include_str!("../../test_data/ws_spot_market_stats_update_all.json");
1690
1691 fn stub_eth_perp_instrument() -> InstrumentAny {
1692 let instrument_id = InstrumentId::new(Symbol::new("ETH-PERP"), Venue::new("LIGHTER"));
1693 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
1694 instrument_id,
1695 Symbol::new("ETH-PERP"),
1696 Currency::from("ETH"),
1697 Currency::from("USDC"),
1698 Currency::from("USDC"),
1699 false,
1700 2,
1701 4,
1702 Price::from("0.01"),
1703 Quantity::from("0.0001"),
1704 None,
1705 None,
1706 None,
1707 None,
1708 None,
1709 None,
1710 None,
1711 None,
1712 None,
1713 None,
1714 None,
1715 None,
1716 None,
1717 None,
1718 UnixNanos::default(),
1719 UnixNanos::default(),
1720 ))
1721 }
1722
1723 fn stub_eth_spot_instrument() -> InstrumentAny {
1724 let instrument_id = InstrumentId::new(Symbol::new("ETH-SPOT"), Venue::new("LIGHTER"));
1725 InstrumentAny::CurrencyPair(CurrencyPair::new(
1726 instrument_id,
1727 Symbol::new("ETH-SPOT"),
1728 Currency::from("ETH"),
1729 Currency::from("USDC"),
1730 2,
1731 4,
1732 Price::from("0.01"),
1733 Quantity::from("0.0001"),
1734 None,
1735 None,
1736 None,
1737 None,
1738 None,
1739 None,
1740 None,
1741 None,
1742 None,
1743 None,
1744 None,
1745 None,
1746 None,
1747 None,
1748 UnixNanos::default(),
1749 UnixNanos::default(),
1750 ))
1751 }
1752
1753 fn make_handler_with_account() -> FeedHandler {
1754 let signal = Arc::new(AtomicBool::new(false));
1755 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
1756 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
1757 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
1758 let mut handler =
1759 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
1760 handler.instruments.insert(0, stub_eth_perp_instrument());
1761 handler.exec_account = Some((AccountId::from("LIGHTER-1234"), 1234));
1762 handler
1763 }
1764
1765 fn strip_account_marker(mut msgs: Vec<NautilusWsMessage>) -> Vec<NautilusWsMessage> {
1770 if matches!(
1771 msgs.last(),
1772 Some(NautilusWsMessage::AccountStreamFirstFrame(_)),
1773 ) {
1774 msgs.pop();
1775 }
1776 msgs
1777 }
1778
1779 #[rstest]
1780 fn handle_frame_routes_account_orders_to_execution_reports() {
1781 let mut handler = make_handler_with_account();
1782 let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
1783
1784 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1785
1786 assert_eq!(messages.len(), 1);
1787 match &messages[0] {
1788 NautilusWsMessage::ExecutionReports(reports) => {
1789 assert_eq!(reports.len(), 1);
1790 match &reports[0] {
1791 super::ExecutionReport::Order(order) => {
1792 assert_eq!(order.order_id, "281476929510110");
1793 assert_eq!(order.client_order_id, "42");
1794 }
1795 other => panic!("expected order report, was {other:?}"),
1796 }
1797 }
1798 other => panic!("expected execution reports, was {other:?}"),
1799 }
1800 }
1801
1802 #[rstest]
1803 fn handle_frame_routes_account_trades_to_execution_reports() {
1804 let mut handler = make_handler_with_account();
1805 let frame: super::LighterWsFrame =
1806 serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
1807
1808 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1809
1810 assert_eq!(messages.len(), 1);
1811 match &messages[0] {
1812 NautilusWsMessage::ExecutionReports(reports) => {
1813 assert_eq!(reports.len(), 1);
1814 match &reports[0] {
1815 super::ExecutionReport::Fill(fill) => {
1816 assert_eq!(fill.bid_id_str.as_deref(), Some("562947905631053"),);
1822 }
1823 other => panic!("expected fill report, was {other:?}"),
1824 }
1825 }
1826 other => panic!("expected execution reports, was {other:?}"),
1827 }
1828 }
1829
1830 #[rstest]
1831 fn handle_frame_routes_account_positions_to_position_snapshot() {
1832 let mut handler = make_handler_with_account();
1833 let frame: super::LighterWsFrame =
1834 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1835
1836 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1837
1838 assert_eq!(messages.len(), 1);
1839 match &messages[0] {
1840 NautilusWsMessage::PositionSnapshot {
1841 reports,
1842 skipped_market_ids,
1843 } => {
1844 assert!(skipped_market_ids.is_empty());
1845 assert_eq!(reports.len(), 1);
1846 assert_eq!(reports[0].quantity, Quantity::from("1.5000"));
1847 }
1848 other => panic!("expected position snapshot, was {other:?}"),
1849 }
1850 }
1851
1852 #[rstest]
1853 fn handle_frame_routes_empty_account_positions_to_empty_snapshot() {
1854 let mut handler = make_handler_with_account();
1856 let frame_json = serde_json::json!({
1857 "type": "update/account_all_positions",
1858 "channel": "account_all_positions:1234",
1859 "positions": {},
1860 "shares": [],
1861 "last_funding_round": null,
1862 "last_funding_discount": null,
1863 });
1864 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1865
1866 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1867
1868 assert_eq!(messages.len(), 1);
1869 match &messages[0] {
1870 NautilusWsMessage::PositionSnapshot {
1871 reports,
1872 skipped_market_ids,
1873 } => {
1874 assert!(skipped_market_ids.is_empty());
1875 assert!(reports.is_empty());
1876 }
1877 other => panic!("expected empty position snapshot, was {other:?}"),
1878 }
1879 }
1880
1881 #[rstest]
1882 fn handle_frame_marks_account_positions_incomplete_when_position_instrument_uncached() {
1883 let mut handler = make_handler_with_account();
1884 let mut frame_json: serde_json::Value =
1885 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1886 frame_json["positions"]["0"]["market_id"] = json!(999);
1887 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1888
1889 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1890
1891 assert_eq!(messages.len(), 1);
1892 match &messages[0] {
1893 NautilusWsMessage::PositionSnapshot {
1894 reports,
1895 skipped_market_ids,
1896 } => {
1897 assert_eq!(skipped_market_ids, &[999]);
1898 assert!(reports.is_empty());
1899 }
1900 other => panic!("expected incomplete position snapshot, was {other:?}"),
1901 }
1902 }
1903
1904 #[rstest]
1905 fn handle_frame_marks_account_positions_incomplete_when_position_parse_fails() {
1906 let mut handler = make_handler_with_account();
1907 let mut frame_json: serde_json::Value =
1908 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
1909 frame_json["positions"]["0"]["position"] = json!("-1.5000");
1910 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1911
1912 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1913
1914 assert_eq!(messages.len(), 1);
1915 match &messages[0] {
1916 NautilusWsMessage::PositionSnapshot {
1917 reports,
1918 skipped_market_ids,
1919 } => {
1920 assert_eq!(skipped_market_ids, &[0]);
1921 assert!(reports.is_empty());
1922 }
1923 other => panic!("expected incomplete position snapshot, was {other:?}"),
1924 }
1925 }
1926
1927 #[rstest]
1928 fn handle_frame_routes_subscribed_account_all_positions_alias() {
1929 let mut handler = make_handler_with_account();
1935 let frame_json = serde_json::json!({
1936 "type": "subscribed/account_all_positions",
1937 "channel": "account_all_positions:1234",
1938 "positions": {
1939 "0": {
1940 "allocated_margin": "0.000000",
1941 "avg_entry_price": "0.111230",
1942 "initial_margin_fraction": "10.00",
1943 "liquidation_price": "0.100598",
1944 "margin_mode": 0,
1945 "market_id": 0,
1946 "open_order_count": 0,
1947 "pending_order_count": 0,
1948 "position": "100",
1949 "position_tied_order_count": 0,
1950 "position_value": "11.123000",
1951 "realized_pnl": "0.000000",
1952 "sign": 1,
1953 "symbol": "ETH",
1954 "total_discount": "0.000000",
1955 "total_funding_paid_out": "0.000000",
1956 "unrealized_pnl": "0.000000"
1957 }
1958 },
1959 });
1960 let frame: super::LighterWsFrame = serde_json::from_value(frame_json).unwrap();
1961
1962 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
1963
1964 assert_eq!(messages.len(), 1);
1965 match &messages[0] {
1966 NautilusWsMessage::PositionSnapshot {
1967 reports,
1968 skipped_market_ids,
1969 } => {
1970 assert!(skipped_market_ids.is_empty());
1971 assert_eq!(reports.len(), 1);
1972 assert_eq!(reports[0].quantity, Quantity::from("100"));
1973 }
1974 other => panic!("expected position snapshot, was {other:?}"),
1975 }
1976 }
1977
1978 #[rstest]
1979 #[case::connected(serde_json::json!({"type": "connected", "session_id": "x"}), true, false)]
1980 #[case::ping(serde_json::json!({"type": "ping"}), true, false)]
1981 #[case::pong(serde_json::json!({"type": "pong"}), true, false)]
1982 #[case::send_tx_ack(
1983 serde_json::json!({"type": "jsonapi/sendtx", "code": 200, "tx_hash": "abc"}),
1984 true,
1985 true,
1986 )]
1987 #[case::error_frame(
1988 serde_json::json!({"type": "error", "code": 21727, "message": "invalid client order index"}),
1989 true,
1990 true,
1991 )]
1992 #[case::error_frame_integrator_not_approved(
1993 serde_json::json!({"type": "error", "code": 21149, "message": "integrator is not approved"}),
1994 true,
1995 true,
1996 )]
1997 #[case::send_tx_ack_integrator_not_approved(
1998 serde_json::json!({"type": "jsonapi/sendtx", "code": 21149, "message": "integrator is not approved"}),
1999 true,
2000 true,
2001 )]
2002 #[case::wrapped_error_integrator_not_approved(
2003 serde_json::json!({"error": {"code": 21149, "message": "integrator is not approved"}}),
2004 true,
2005 true,
2006 )]
2007 #[case::subscription_error_frame(
2008 serde_json::json!({"type": "error", "code": 30003, "message": "Already Subscribed to : ticker:3"}),
2009 true,
2010 false,
2011 )]
2012 #[case::wrapped_subscription_error(
2013 serde_json::json!({"error": {"code": 30003, "message": "Already Subscribed to : ticker:3"}}),
2014 true,
2015 false,
2016 )]
2017 #[case::codeless_error_frame(
2018 serde_json::json!({"type": "error", "message": "unclassifiable"}),
2019 true,
2020 false,
2021 )]
2022 #[case::unknown_type(
2023 serde_json::json!({"type": "something_unexpected", "payload": "x"}),
2024 false,
2025 false,
2026 )]
2027 #[case::no_type_field(
2028 serde_json::json!({"error": {"code": 21702, "message": "invalid price"}}),
2029 true,
2030 true,
2031 )]
2032 fn handle_control_text_tri_state(
2033 #[case] payload: serde_json::Value,
2034 #[case] expected_matched: bool,
2035 #[case] expected_has_msg: bool,
2036 ) {
2037 let mut handler = make_handler_with_account();
2044 let text = payload.to_string();
2045 let (matched, msg) = handler.handle_control_text(&text);
2046 assert_eq!(matched, expected_matched, "matched flag");
2047 assert_eq!(msg.is_some(), expected_has_msg, "msg presence");
2048 }
2049
2050 #[rstest]
2051 fn handle_control_text_sendtx_success_emits_typed_ack() {
2052 let mut handler = make_handler_with_account();
2053 let payload = serde_json::json!({
2054 "type": "jsonapi/sendtx",
2055 "code": 200,
2056 "tx_hash": "0000abcd",
2057 })
2058 .to_string();
2059
2060 let (_, msg) = handler.handle_control_text(&payload);
2061
2062 match msg.expect("SendTxAck emitted") {
2063 NautilusWsMessage::SendTxAck { tx_hash, code } => {
2064 assert_eq!(code, 200);
2065 assert_eq!(tx_hash.as_deref(), Some("0000abcd"));
2066 }
2067 other => panic!("expected SendTxAck, was {other:?}"),
2068 }
2069 }
2070
2071 #[rstest]
2072 fn handle_control_text_sendtx_failure_emits_ack_sourced_rejection() {
2073 let mut handler = make_handler_with_account();
2074 let payload = serde_json::json!({
2075 "type": "jsonapi/sendtx",
2076 "code": 21727,
2077 "message": "invalid client order index",
2078 })
2079 .to_string();
2080
2081 let (_, msg) = handler.handle_control_text(&payload);
2082
2083 match msg.expect("SendTxRejected emitted") {
2084 NautilusWsMessage::SendTxRejected {
2085 source,
2086 code,
2087 message,
2088 tx_hash,
2089 } => {
2090 assert_eq!(source, SendTxRejectionSource::Ack);
2091 assert_eq!(code, Some(21727));
2092 assert_eq!(message, "invalid client order index");
2093 assert_eq!(tx_hash, None);
2094 }
2095 other => panic!("expected SendTxRejected, was {other:?}"),
2096 }
2097 }
2098
2099 #[rstest]
2100 fn handle_control_text_sendtx_failure_carries_echoed_tx_hash() {
2101 let mut handler = make_handler_with_account();
2102 let payload = serde_json::json!({
2103 "type": "jsonapi/sendtx",
2104 "code": 21727,
2105 "message": "invalid client order index",
2106 "tx_hash": "0000abcd",
2107 })
2108 .to_string();
2109
2110 let (_, msg) = handler.handle_control_text(&payload);
2111
2112 match msg.expect("SendTxRejected emitted") {
2113 NautilusWsMessage::SendTxRejected { tx_hash, .. } => {
2114 assert_eq!(tx_hash.as_deref(), Some("0000abcd"));
2115 }
2116 other => panic!("expected SendTxRejected, was {other:?}"),
2117 }
2118 }
2119
2120 #[rstest]
2121 fn handle_control_text_bare_error_frame_emits_bare_error_rejection() {
2122 let mut handler = make_handler_with_account();
2123 let payload = serde_json::json!({
2124 "type": "error",
2125 "code": 21702,
2126 "message": "invalid price",
2127 })
2128 .to_string();
2129
2130 let (_, msg) = handler.handle_control_text(&payload);
2131
2132 match msg.expect("SendTxRejected emitted") {
2133 NautilusWsMessage::SendTxRejected {
2134 source,
2135 code,
2136 message,
2137 tx_hash,
2138 } => {
2139 assert_eq!(source, SendTxRejectionSource::BareError);
2140 assert_eq!(code, Some(21702));
2141 assert_eq!(message, "invalid price");
2142 assert_eq!(tx_hash, None);
2143 }
2144 other => panic!("expected SendTxRejected, was {other:?}"),
2145 }
2146 }
2147
2148 #[rstest]
2149 fn handle_control_text_wrapped_error_emits_bare_error_rejection() {
2150 let mut handler = make_handler_with_account();
2152 let payload = serde_json::json!({
2153 "error": {"code": 21149, "message": "integrator is not approved"},
2154 })
2155 .to_string();
2156
2157 let (_, msg) = handler.handle_control_text(&payload);
2158
2159 match msg.expect("SendTxRejected emitted") {
2160 NautilusWsMessage::SendTxRejected {
2161 source,
2162 code,
2163 message,
2164 tx_hash,
2165 } => {
2166 assert_eq!(source, SendTxRejectionSource::BareError);
2167 assert_eq!(code, Some(21149));
2168 assert_eq!(message, "integrator is not approved");
2169 assert_eq!(tx_hash, None);
2170 }
2171 other => panic!("expected SendTxRejected, was {other:?}"),
2172 }
2173 }
2174
2175 #[rstest]
2176 fn handle_frame_emits_no_account_state_until_both_streams_seen() {
2177 let mut handler = make_handler_with_account();
2181 let assets_only: super::LighterWsFrame =
2182 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2183
2184 let messages = strip_account_marker(handler.handle_frame(assets_only, UnixNanos::from(11)));
2185
2186 assert!(
2187 messages.is_empty(),
2188 "expected no AccountState before user_stats arrives, received {messages:?}"
2189 );
2190 }
2191
2192 #[rstest]
2193 fn handle_frame_routes_account_assets_and_user_stats_to_unified_state() {
2194 let mut handler = make_handler_with_account();
2201 let assets_frame: super::LighterWsFrame =
2202 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2203 let user_stats_frame: super::LighterWsFrame =
2204 serde_json::from_str(WS_USER_STATS_UPDATE).unwrap();
2205
2206 let _ = handler.handle_frame(assets_frame, UnixNanos::from(11));
2207 let messages =
2208 strip_account_marker(handler.handle_frame(user_stats_frame, UnixNanos::from(12)));
2209
2210 assert_eq!(messages.len(), 1);
2211 match &messages[0] {
2212 NautilusWsMessage::AccountState(state) => {
2213 let usdc = Currency::get_or_create_crypto("USDC");
2214 assert_eq!(state.account_type, AccountType::Margin);
2215 assert_eq!(state.base_currency, None);
2216 assert_eq!(state.balances.len(), 1);
2217 assert_eq!(state.balances[0].currency, usdc);
2218 assert_eq!(state.balances[0].total, Money::from("50.000000 USDC"));
2221 assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2222 assert_eq!(state.balances[0].free, Money::from("50.000000 USDC"));
2223 assert_eq!(state.margins.len(), 1);
2224 assert_eq!(state.margins[0].currency, usdc);
2225 assert_eq!(state.margins[0].initial, Money::from("0 USDC"));
2226 assert_eq!(state.margins[0].maintenance, Money::from("0 USDC"));
2227 assert!(state.margins[0].instrument_id.is_none());
2228 assert!(state.is_reported);
2229 }
2230 other => panic!("expected account state, was {other:?}"),
2231 }
2232 }
2233
2234 #[rstest]
2235 fn handle_frame_unified_state_reflects_open_position() {
2236 let mut handler = make_handler_with_account();
2247 let assets_frame: super::LighterWsFrame =
2248 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_WITH_POSITION).unwrap();
2249 let user_stats_frame: super::LighterWsFrame =
2250 serde_json::from_str(WS_USER_STATS_WITH_POSITION).unwrap();
2251
2252 let _ = handler.handle_frame(assets_frame, UnixNanos::from(11));
2253 let messages =
2254 strip_account_marker(handler.handle_frame(user_stats_frame, UnixNanos::from(12)));
2255
2256 assert_eq!(messages.len(), 1);
2257 match &messages[0] {
2258 NautilusWsMessage::AccountState(state) => {
2259 assert_eq!(state.account_type, AccountType::Margin);
2260 assert_eq!(state.base_currency, None);
2261 assert_eq!(state.balances.len(), 1);
2262 assert_eq!(state.balances[0].total, Money::from("49.99536956 USDC"));
2266 assert_eq!(state.balances[0].locked, Money::from("0 USDC"));
2267 assert_eq!(state.balances[0].free, Money::from("49.99536956 USDC"));
2268 assert_eq!(state.margins.len(), 1);
2269 assert_eq!(state.margins[0].initial, Money::from("0.82705500 USDC"));
2271 assert_eq!(state.margins[0].maintenance, Money::from("0 USDC"));
2272 assert!(state.margins[0].instrument_id.is_none());
2273 }
2274 other => panic!("expected account state, was {other:?}"),
2275 }
2276 }
2277
2278 #[rstest]
2279 fn handle_frame_emits_account_stream_first_frame_marker_per_variant() {
2280 let mut handler = make_handler_with_account();
2286 let orders_frame: super::LighterWsFrame =
2287 serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2288 let trades_frame: super::LighterWsFrame =
2289 serde_json::from_str(WS_ACCOUNT_ALL_TRADES_UPDATE).unwrap();
2290 let positions_frame: super::LighterWsFrame =
2291 serde_json::from_str(WS_ACCOUNT_ALL_POSITIONS_UPDATE).unwrap();
2292 let assets_frame: super::LighterWsFrame =
2293 serde_json::from_str(WS_ACCOUNT_ALL_ASSETS_UPDATE).unwrap();
2294 let user_stats_frame: super::LighterWsFrame =
2295 serde_json::from_str(WS_USER_STATS_UPDATE).unwrap();
2296
2297 let cases = [
2298 (orders_frame, AccountStream::Orders),
2299 (trades_frame, AccountStream::Trades),
2300 (positions_frame, AccountStream::Positions),
2301 (assets_frame, AccountStream::Assets),
2302 (user_stats_frame, AccountStream::UserStats),
2303 ];
2304
2305 for (frame, expected) in cases {
2306 let msgs = handler.handle_frame(frame, UnixNanos::from(11));
2307 let marker = msgs
2308 .iter()
2309 .find(|m| matches!(m, NautilusWsMessage::AccountStreamFirstFrame(_)))
2310 .unwrap_or_else(|| panic!("missing marker for {expected:?}"));
2311 match marker {
2312 NautilusWsMessage::AccountStreamFirstFrame(stream) => {
2313 assert_eq!(*stream, expected);
2314 }
2315 other => panic!("expected AccountStreamFirstFrame, was {other:?}"),
2316 }
2317 assert!(
2320 matches!(
2321 msgs.last(),
2322 Some(NautilusWsMessage::AccountStreamFirstFrame(_)),
2323 ),
2324 "marker must trail typed reports for {expected:?}",
2325 );
2326 }
2327 }
2328
2329 #[rstest]
2330 fn handle_frame_account_orders_without_context_falls_back_to_raw() {
2331 let signal = Arc::new(AtomicBool::new(false));
2332 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2333 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2334 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2335 let mut handler =
2336 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2337 handler.instruments.insert(0, stub_eth_perp_instrument());
2338 let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2342 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2343
2344 assert_eq!(messages.len(), 1);
2345 match &messages[0] {
2346 NautilusWsMessage::Raw(value) => {
2347 assert_eq!(value["type"], "update/account_orders");
2348 }
2349 other => panic!("expected raw fallback, was {other:?}"),
2350 }
2351 }
2352
2353 #[rstest]
2354 fn handle_frame_account_orders_skips_unknown_market() {
2355 let signal = Arc::new(AtomicBool::new(false));
2361 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2362 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2363 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2364 let mut handler =
2365 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2366 handler.exec_account = Some((AccountId::from("LIGHTER-1234"), 1234));
2367 let frame: super::LighterWsFrame = serde_json::from_str(WS_ACCOUNT_ORDERS_UPDATE).unwrap();
2370 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2371
2372 assert!(messages.is_empty());
2373 }
2374
2375 #[rstest]
2376 fn handle_frame_account_assets_invalid_timestamp_returns_empty() {
2377 let mut handler = make_handler_with_account();
2378 let frame_json = r#"{
2385 "type": "update/account_all_assets",
2386 "channel": "account_all_assets:1234",
2387 "timestamp": 18446744073709551615,
2388 "assets": {
2389 "0": {
2390 "symbol": "USDC",
2391 "asset_id": 0,
2392 "balance": "100.000000",
2393 "locked_balance": "1.000000"
2394 }
2395 }
2396 }"#;
2397 let frame: super::LighterWsFrame = serde_json::from_str(frame_json).unwrap();
2398
2399 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2400
2401 assert!(messages.is_empty());
2402 }
2403
2404 #[rstest]
2405 fn handle_frame_account_all_orders_routes_to_execution_reports() {
2406 let mut handler = make_handler_with_account();
2410 let frame_json = r#"{
2411 "type": "update/account_all_orders",
2412 "channel": "account_all_orders:1234",
2413 "orders": {
2414 "0": [{
2415 "order_index": 281476929510110,
2416 "client_order_index": 42,
2417 "order_id": "281476929510110",
2418 "client_order_id": "42",
2419 "market_index": 0,
2420 "owner_account_index": 1234,
2421 "initial_base_amount": "0.0050",
2422 "price": "2352.74",
2423 "nonce": 9182390020,
2424 "remaining_base_amount": "0.0050",
2425 "is_ask": true,
2426 "base_size": 50,
2427 "base_price": 235274,
2428 "filled_base_amount": "0.0000",
2429 "filled_quote_amount": "0.000000",
2430 "side": "sell",
2431 "type": "limit",
2432 "time_in_force": "good-till-time",
2433 "reduce_only": false,
2434 "trigger_price": "0.00",
2435 "order_expiry": 1780360584479,
2436 "status": "open",
2437 "trigger_status": "na",
2438 "trigger_time": 0,
2439 "parent_order_index": 0,
2440 "parent_order_id": "0",
2441 "to_trigger_order_id_0": "0",
2442 "to_trigger_order_id_1": "0",
2443 "to_cancel_order_id_0": "0",
2444 "integrator_fee_collector_index": "0",
2445 "integrator_taker_fee": "0",
2446 "integrator_maker_fee": "0",
2447 "block_height": 227535532,
2448 "timestamp": 1777941383576,
2449 "created_at": 1777941383576,
2450 "updated_at": 1777941383576,
2451 "transaction_time": 1777941383576735
2452 }]
2453 }
2454 }"#;
2455 let frame: super::LighterWsFrame = serde_json::from_str(frame_json).unwrap();
2456
2457 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2458
2459 assert_eq!(messages.len(), 1);
2460 match &messages[0] {
2461 NautilusWsMessage::ExecutionReports(reports) => {
2462 assert_eq!(reports.len(), 1);
2463 match &reports[0] {
2464 super::ExecutionReport::Order(order) => {
2465 assert_eq!(order.order_id, "281476929510110");
2466 }
2467 other => panic!("expected order report, was {other:?}"),
2468 }
2469 }
2470 other => panic!("expected execution reports, was {other:?}"),
2471 }
2472 }
2473
2474 fn snapshot_trade_frame_json() -> &'static str {
2475 r#"{
2476 "type": "subscribed/account_all_trades",
2477 "channel": "account_all_trades:1234",
2478 "trades": [{
2479 "trade_id": 19209006902,
2480 "trade_id_str": "19209006902",
2481 "tx_hash": "000000128b1ee814",
2482 "type": "trade",
2483 "market_id": 0,
2484 "size": "0.1336",
2485 "price": "2352.73",
2486 "usd_amount": "314.324728",
2487 "ask_id": 281476929510102,
2488 "bid_id": 562947905631053,
2489 "ask_client_id": 0,
2490 "bid_client_id": 7001011966,
2491 "ask_account_id": 91249,
2492 "bid_account_id": 1234,
2493 "is_maker_ask": true,
2494 "block_height": 227535535,
2495 "timestamp": 1777941384181,
2496 "transaction_time": 1777941384181586
2497 }],
2498 "total_volume": "100.0",
2499 "monthly_volume": "100.0",
2500 "weekly_volume": "100.0",
2501 "daily_volume": "100.0"
2502 }"#
2503 }
2504
2505 #[rstest]
2506 fn handle_frame_account_all_trades_snapshot_is_dropped_with_context() {
2507 let mut handler = make_handler_with_account();
2508 let frame: super::LighterWsFrame =
2513 serde_json::from_str(snapshot_trade_frame_json()).unwrap();
2514
2515 let messages = strip_account_marker(handler.handle_frame(frame, UnixNanos::from(11)));
2516
2517 assert!(messages.is_empty());
2518 }
2519
2520 #[rstest]
2521 fn handle_frame_account_all_trades_snapshot_falls_back_to_raw_without_context() {
2522 let signal = Arc::new(AtomicBool::new(false));
2523 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2524 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2525 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2526 let mut handler =
2527 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2528 handler.instruments.insert(0, stub_eth_perp_instrument());
2529 let frame: super::LighterWsFrame =
2533 serde_json::from_str(snapshot_trade_frame_json()).unwrap();
2534 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2535
2536 assert_eq!(messages.len(), 1);
2537 match &messages[0] {
2538 NautilusWsMessage::Raw(value) => {
2539 assert_eq!(value["type"], "subscribed/account_all_trades");
2540 }
2541 other => panic!("expected raw fallback, was {other:?}"),
2542 }
2543 }
2544
2545 #[rstest]
2546 fn handle_frame_market_stats_emits_mark_index_and_funding_updates() {
2547 let mut handler = make_handler_with_account();
2548 let frame: super::LighterWsFrame =
2549 serde_json::from_str(WS_MARKET_STATS_UPDATE_SINGLE).unwrap();
2550
2551 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2552
2553 assert_eq!(messages.len(), 3);
2554 match &messages[0] {
2555 NautilusWsMessage::MarkPrice(update) => {
2556 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2557 assert_eq!(update.value, Price::from("2064.47"));
2558 assert_eq!(update.ts_event, UnixNanos::from(1_774_883_844_933_000_000));
2559 }
2560 event => panic!("expected mark price update, was {event:?}"),
2561 }
2562
2563 match &messages[1] {
2564 NautilusWsMessage::IndexPrice(update) => {
2565 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2566 assert_eq!(update.value, Price::from("2064.48"));
2567 }
2568 event => panic!("expected index price update, was {event:?}"),
2569 }
2570
2571 match &messages[2] {
2572 NautilusWsMessage::FundingRate(update) => {
2573 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2574 assert_eq!(update.rate.to_string(), "0.000001");
2575 assert_eq!(
2576 update.next_funding_ns,
2577 Some(UnixNanos::from(1_774_886_400_000_000_000))
2578 );
2579 }
2580 event => panic!("expected funding rate update, was {event:?}"),
2581 }
2582 }
2583
2584 #[rstest]
2585 fn handle_frame_market_stats_all_emits_mark_index_and_funding_updates() {
2586 let mut handler = make_handler_with_account();
2587 let frame: super::LighterWsFrame =
2588 serde_json::from_str(WS_MARKET_STATS_UPDATE_ALL).unwrap();
2589
2590 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2591
2592 assert_eq!(messages.len(), 3);
2593 assert!(matches!(&messages[0], NautilusWsMessage::MarkPrice(_)));
2594 assert!(matches!(&messages[1], NautilusWsMessage::IndexPrice(_)));
2595 assert!(matches!(&messages[2], NautilusWsMessage::FundingRate(_)));
2596 match &messages[0] {
2597 NautilusWsMessage::MarkPrice(update) => {
2598 assert_eq!(update.instrument_id.to_string(), "ETH-PERP.LIGHTER");
2599 assert_eq!(update.value, Price::from("2064.47"));
2600 }
2601 event => panic!("expected mark price update, was {event:?}"),
2602 }
2603 }
2604
2605 #[rstest]
2606 fn handle_frame_spot_market_stats_emits_index_update() {
2607 let signal = Arc::new(AtomicBool::new(false));
2608 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2609 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2610 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2611 let mut handler =
2612 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2613 handler.instruments.insert(2048, stub_eth_spot_instrument());
2614 let frame: super::LighterWsFrame =
2615 serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_SINGLE).unwrap();
2616
2617 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2618
2619 assert_eq!(messages.len(), 1);
2620 match &messages[0] {
2621 NautilusWsMessage::IndexPrice(update) => {
2622 assert_eq!(update.instrument_id.to_string(), "ETH-SPOT.LIGHTER");
2623 assert_eq!(update.value, Price::from("1.00"));
2624 }
2625 event => panic!("expected spot index price update, was {event:?}"),
2626 }
2627 }
2628
2629 #[rstest]
2630 fn handle_frame_spot_market_stats_all_emits_index_update() {
2631 let signal = Arc::new(AtomicBool::new(false));
2632 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2633 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2634 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2635 let mut handler =
2636 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2637 handler.instruments.insert(2048, stub_eth_spot_instrument());
2638 let frame: super::LighterWsFrame =
2639 serde_json::from_str(WS_SPOT_MARKET_STATS_UPDATE_ALL).unwrap();
2640
2641 let messages = handler.handle_frame(frame, UnixNanos::from(11));
2642
2643 assert_eq!(messages.len(), 1);
2644 match &messages[0] {
2645 NautilusWsMessage::IndexPrice(update) => {
2646 assert_eq!(update.instrument_id.to_string(), "ETH-SPOT.LIGHTER");
2647 assert_eq!(update.value, Price::from("1.00"));
2648 }
2649 event => panic!("expected spot index price update, was {event:?}"),
2650 }
2651 }
2652
2653 #[rstest]
2654 #[case(LighterWsChannel::OrderBook(0), "order_book:0", "order_book/0")]
2655 #[case(LighterWsChannel::Trade(7), "trade:7", "trade/7")]
2656 #[case(LighterWsChannel::Ticker(2), "ticker:2", "ticker/2")]
2657 #[case(LighterWsChannel::Height, "height", "height")]
2658 #[case(
2659 LighterWsChannel::MarketStats(LighterMarketSelection::All),
2660 "market_stats:all",
2661 "market_stats/all"
2662 )]
2663 #[case(
2664 LighterWsChannel::SpotMarketStats(LighterMarketSelection::Market(2048)),
2665 "spot_market_stats:2048",
2666 "spot_market_stats/2048"
2667 )]
2668 #[case(
2669 LighterWsChannel::AccountOrders { market_index: 0, account_index: 1234 },
2670 "account_orders:0:1234",
2671 "account_orders/0/1234",
2672 )]
2673 fn topic_and_subscription_round_trip(
2674 #[case] channel: LighterWsChannel,
2675 #[case] expected_topic: &str,
2676 #[case] expected_subscription: &str,
2677 ) {
2678 assert_eq!(channel.topic_key(), expected_topic);
2679 assert_eq!(channel.subscription_channel(), expected_subscription);
2680 }
2681
2682 #[rstest]
2683 #[case("order_book:0", Some(0))]
2684 #[case("trade:42", Some(42))]
2685 #[case("height", None)]
2686 #[case("malformed", None)]
2687 fn market_index_extraction(#[case] topic: &str, #[case] expected: Option<i16>) {
2688 assert_eq!(market_index_from_topic(topic), expected);
2689 }
2690
2691 #[rstest]
2692 #[case("order_book:0", Some(0))]
2693 #[case("order_book:42", Some(42))]
2694 #[case("trade:42", None)]
2695 #[case("ticker:2", None)]
2696 #[case("market_stats:0", None)]
2697 #[case("height", None)]
2698 #[case("order_book:not-an-int", None)]
2699 fn order_book_market_index_only_matches_order_book_channel(
2700 #[case] topic: &str,
2701 #[case] expected: Option<i16>,
2702 ) {
2703 assert_eq!(order_book_market_index_from_topic(topic), expected);
2704 }
2705
2706 #[rstest]
2707 #[case(LighterWsChannel::AccountAll(1234), true)]
2708 #[case(LighterWsChannel::OrderBook(0), false)]
2709 #[case(LighterWsChannel::AccountAllPositions(1), true)]
2710 #[case(LighterWsChannel::Trade(0), false)]
2711 fn requires_auth_classification(#[case] channel: LighterWsChannel, #[case] expected: bool) {
2712 assert_eq!(channel.requires_auth(), expected);
2713 }
2714
2715 #[rstest]
2716 fn handler_command_subscribe_debug_redacts_auth_token() {
2717 let token = "schnorr-signature-bytes-do-not-leak";
2718 let cmd = HandlerCommand::Subscribe {
2719 channel: LighterWsChannel::AccountAll(1234),
2720 auth: Some(token.to_string()),
2721 };
2722
2723 let dbg = format!("{cmd:?}");
2724
2725 assert!(
2726 !dbg.contains(token),
2727 "Debug output must not contain the auth token, found: {dbg}",
2728 );
2729 assert!(dbg.contains("authed"), "Debug should include authed flag");
2730 }
2731
2732 #[tokio::test]
2733 async fn send_tx_command_returns_handler_send_error_without_active_client() {
2734 let signal = Arc::new(AtomicBool::new(false));
2735 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2736 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2737 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2738 let mut handler =
2739 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
2740 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
2741
2742 cmd_tx
2743 .send(HandlerCommand::SendTx {
2744 tx_type: LighterTxType::CreateOrder as u8,
2745 tx_info: serde_json::value::RawValue::from_string(
2746 r#"{"AccountIndex":12345,"Nonce":42}"#.to_string(),
2747 )
2748 .unwrap(),
2749 response_tx,
2750 })
2751 .unwrap();
2752 drop(cmd_tx);
2753 drop(raw_tx);
2754
2755 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
2756 .await
2757 .expect("timed out waiting for handler to drain command");
2758 let result = response_rx.await.expect("sendTx response channel closed");
2759
2760 assert!(next.is_none());
2761 let Err(LighterWsError::Client(message)) = result else {
2762 panic!("expected client send error, was {result:?}");
2763 };
2764 assert!(message.contains("no active WebSocket client"));
2765 }
2766
2767 #[tokio::test]
2768 async fn resubscribe_order_book_command_skips_when_reference_removed() {
2769 let signal = Arc::new(AtomicBool::new(false));
2770 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
2771 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
2772 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
2773 let subscriptions = SubscriptionState::new(':');
2774 let topic = LighterWsChannel::OrderBook(0).topic_key();
2775 assert!(subscriptions.add_reference(&topic));
2776 assert!(subscriptions.remove_reference(&topic));
2777
2778 let mut handler = FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, subscriptions.clone());
2779 handler.book_delta_subs.insert(0);
2780
2781 cmd_tx
2782 .send(HandlerCommand::ResubscribeOrderBook { market_index: 0 })
2783 .expect("queue resync");
2784 drop(cmd_tx);
2785 drop(raw_tx);
2786
2787 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
2788 .await
2789 .expect("timed out waiting for handler to drain command");
2790
2791 assert!(next.is_none());
2792 assert!(subscriptions.pending_subscribe_topics().is_empty());
2793 assert!(subscriptions.pending_unsubscribe_topics().is_empty());
2794 }
2795
2796 fn stub_candle(
2797 t: i64,
2798 open: i64,
2799 high: i64,
2800 low: i64,
2801 close: i64,
2802 volume_ticks: i64,
2803 ) -> LighterWsCandle {
2804 LighterWsCandle {
2805 t,
2806 o: Decimal::new(open, 2),
2807 h: Decimal::new(high, 2),
2808 l: Decimal::new(low, 2),
2809 c: Decimal::new(close, 2),
2810 v: Decimal::new(volume_ticks, 4),
2811 quote_volume: Decimal::ZERO,
2812 i: 0,
2813 }
2814 }
2815
2816 fn candle_frame(channel: &str, candle: LighterWsCandle, is_snapshot: bool) -> LighterWsFrame {
2817 if is_snapshot {
2818 LighterWsFrame::CandleSnapshot {
2819 channel: Ustr::from(channel),
2820 candles: vec![candle],
2821 timestamp: 0,
2822 }
2823 } else {
2824 LighterWsFrame::Candle {
2825 channel: Ustr::from(channel),
2826 candles: vec![candle],
2827 timestamp: 0,
2828 }
2829 }
2830 }
2831
2832 #[rstest]
2833 fn handle_candles_first_observation_caches_without_emit() {
2834 let mut handler = make_handler_with_account();
2835 let frame = candle_frame(
2836 "candle:0:1m",
2837 stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 10_000),
2838 true,
2839 );
2840
2841 let messages = handler.handle_frame(frame, UnixNanos::from(99));
2842
2843 assert!(messages.is_empty(), "first observation must not emit");
2844 let key = (0_i16, LighterCandleResolution::OneMinute);
2845 assert_eq!(handler.last_candles.get(&key).map(|c| c.t), Some(1_000_000));
2846 }
2847
2848 #[rstest]
2849 fn handle_candles_t_advance_emits_bar_for_previous_candle() {
2850 let mut handler = make_handler_with_account();
2851 let prev = stub_candle(1_000_000, 10_000, 11_000, 9_900, 10_500, 10_000);
2852 let next = stub_candle(1_060_000, 10_500, 10_600, 10_450, 10_550, 20_000);
2853 let next_t = next.t;
2854 handler.handle_frame(candle_frame("candle:0:1m", prev, true), UnixNanos::from(1));
2855
2856 let messages =
2857 handler.handle_frame(candle_frame("candle:0:1m", next, false), UnixNanos::from(2));
2858
2859 assert_eq!(messages.len(), 1);
2860 match &messages[0] {
2861 NautilusWsMessage::Bar(bar) => {
2862 assert_eq!(bar.open, Price::from("100.00"));
2864 assert_eq!(bar.high, Price::from("110.00"));
2865 assert_eq!(bar.low, Price::from("99.00"));
2866 assert_eq!(bar.close, Price::from("105.00"));
2867 assert_eq!(bar.volume, Quantity::from("1.0000"));
2868 assert_eq!(bar.ts_event, UnixNanos::from(1_000_000 * 1_000_000));
2869 }
2870 other => panic!("expected Bar message, was {other:?}"),
2871 }
2872 let cached = handler
2873 .last_candles
2874 .get(&(0_i16, LighterCandleResolution::OneMinute))
2875 .expect("cache populated");
2876 assert_eq!(cached.t, next_t);
2877 }
2878
2879 #[rstest]
2880 fn handle_candles_same_t_updates_cache_without_emit() {
2881 let mut handler = make_handler_with_account();
2882 let initial = stub_candle(1_000_000, 10_000, 10_050, 9_950, 10_025, 5_000);
2883 let same_t_updated = stub_candle(1_000_000, 10_000, 10_100, 9_950, 10_075, 7_500);
2884 let same_t_h = same_t_updated.h;
2885 let same_t_c = same_t_updated.c;
2886 handler.handle_frame(
2887 candle_frame("candle:0:1m", initial, true),
2888 UnixNanos::from(1),
2889 );
2890
2891 let messages = handler.handle_frame(
2892 candle_frame("candle:0:1m", same_t_updated, false),
2893 UnixNanos::from(2),
2894 );
2895
2896 assert!(messages.is_empty(), "same-`t` update must not emit");
2897 let cached = handler
2898 .last_candles
2899 .get(&(0_i16, LighterCandleResolution::OneMinute))
2900 .expect("cache populated");
2901 assert_eq!(cached.h, same_t_h);
2902 assert_eq!(cached.c, same_t_c);
2903 }
2904
2905 #[rstest]
2906 fn handle_candles_regressed_t_is_skipped() {
2907 let mut handler = make_handler_with_account();
2908 let initial = stub_candle(2_000_000, 10_000, 10_000, 10_000, 10_000, 5_000);
2909 let regressed = stub_candle(1_000_000, 9_000, 9_000, 9_000, 9_000, 5_000);
2910 let initial_t = initial.t;
2911 handler.handle_frame(
2912 candle_frame("candle:0:1m", initial, true),
2913 UnixNanos::from(1),
2914 );
2915
2916 let messages = handler.handle_frame(
2917 candle_frame("candle:0:1m", regressed, false),
2918 UnixNanos::from(2),
2919 );
2920
2921 assert!(messages.is_empty(), "regressed `t` must not emit");
2922 let cached = handler
2923 .last_candles
2924 .get(&(0_i16, LighterCandleResolution::OneMinute))
2925 .expect("cache populated");
2926 assert_eq!(cached.t, initial_t);
2928 }
2929
2930 #[rstest]
2931 fn handle_candles_unknown_market_returns_empty() {
2932 let mut handler = make_handler_with_account();
2933 let frame = candle_frame(
2934 "candle:99:1m",
2935 stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 5_000),
2936 true,
2937 );
2938
2939 let messages = handler.handle_frame(frame, UnixNanos::from(1));
2940
2941 assert!(messages.is_empty());
2942 }
2943
2944 #[rstest]
2945 fn handle_unsubscribe_ack_clears_only_matching_candle_key() {
2946 let mut handler = make_handler_with_account();
2947 handler.last_candles.insert(
2948 (0, LighterCandleResolution::OneMinute),
2949 stub_candle(1, 0, 0, 0, 0, 0),
2950 );
2951 handler.last_candles.insert(
2952 (0, LighterCandleResolution::FiveMinute),
2953 stub_candle(2, 0, 0, 0, 0, 0),
2954 );
2955 handler.subscriptions.mark_unsubscribe("candle:0:1m");
2956
2957 let payload = json!({"type": "unsubscribed", "channel": "candle:0:1m"});
2958 let (matched, _) = handler.handle_control_text(&payload.to_string());
2959
2960 assert!(matched);
2961 assert!(
2962 handler
2963 .last_candles
2964 .get(&(0, LighterCandleResolution::OneMinute))
2965 .is_none(),
2966 );
2967 assert!(
2968 handler
2969 .last_candles
2970 .get(&(0, LighterCandleResolution::FiveMinute))
2971 .is_some(),
2972 );
2973 }
2974
2975 #[rstest]
2976 #[case::well_formed("candle:0:1m", Some((0, LighterCandleResolution::OneMinute)))]
2977 #[case::weekly("candle:3:1w", Some((3, LighterCandleResolution::OneWeek)))]
2978 #[case::other_kind("order_book:0", None)]
2979 #[case::missing_resolution("candle:0", None)]
2980 #[case::bad_market("candle:notanint:1m", None)]
2981 #[case::bad_resolution("candle:0:bogus", None)]
2982 fn test_candle_market_and_resolution_from_topic(
2983 #[case] topic: &str,
2984 #[case] expected: Option<(i16, LighterCandleResolution)>,
2985 ) {
2986 assert_eq!(candle_market_and_resolution_from_topic(topic), expected);
2987 }
2988
2989 #[rstest]
2990 #[case::network_retries(LighterWsError::Network("disconnected".into()), true)]
2991 #[case::auth_does_not_retry(LighterWsError::Authentication("bad token".into()), false)]
2992 #[case::parse_does_not_retry(LighterWsError::Parse("bad json".into()), false)]
2993 #[case::client_does_not_retry(LighterWsError::Client("no active WebSocket client".into()), false)]
2994 #[case::transport_closed_does_not_retry(LighterWsError::Transport(SendError::Closed), false)]
2995 #[case::transport_timeout_retries(LighterWsError::Transport(SendError::Timeout), true)]
2996 #[case::transport_broken_pipe_does_not_retry(
2997 LighterWsError::Transport(SendError::BrokenPipe(
2998 "writer closed".into(),
2999 )),
3000 false,
3001 )]
3002 fn test_should_retry_lighter_ws_error(#[case] error: LighterWsError, #[case] expected: bool) {
3003 assert_eq!(should_retry_lighter_ws_error(&error), expected);
3004 }
3005
3006 #[rstest]
3008 #[case::closed(SendError::Closed)]
3009 #[case::timeout(SendError::Timeout)]
3010 #[case::broken_pipe(SendError::BrokenPipe("writer dropped".into()))]
3011 fn send_error_converts_into_transport_variant(#[case] send_error: SendError) {
3012 let err: LighterWsError = send_error.into();
3013 assert!(
3014 matches!(err, LighterWsError::Transport(_)),
3015 "expected Transport variant, was {err:?}",
3016 );
3017 }
3018
3019 #[tokio::test]
3020 async fn subscribe_command_parks_in_pending_subs_when_inflight_at_cap() {
3021 let signal = Arc::new(AtomicBool::new(false));
3022 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3023 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3024 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3025 let mut handler =
3026 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3027
3028 for i in 0..SUBSCRIBE_INFLIGHT_MAX {
3030 handler
3031 .inflight_subs
3032 .insert(Ustr::from(format!("dummy:{i}").as_str()));
3033 }
3034
3035 cmd_tx
3036 .send(HandlerCommand::Subscribe {
3037 channel: LighterWsChannel::Candle {
3038 market_index: 0,
3039 resolution: LighterCandleResolution::OneMinute,
3040 },
3041 auth: None,
3042 })
3043 .expect("queue subscribe");
3044 drop(cmd_tx);
3045 drop(raw_tx);
3046
3047 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3048 .await
3049 .expect("timed out waiting for handler to drain command");
3050
3051 assert!(next.is_none());
3052 assert_eq!(handler.inflight_subs.len(), SUBSCRIBE_INFLIGHT_MAX);
3053 assert_eq!(handler.pending_subs.len(), 1);
3054 }
3055
3056 #[tokio::test]
3057 async fn unsubscribe_drops_queued_subscribe_while_gate_full() {
3058 let signal = Arc::new(AtomicBool::new(false));
3059 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3060 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3061 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3062 let mut handler =
3063 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3064
3065 for i in 0..SUBSCRIBE_INFLIGHT_MAX {
3067 handler
3068 .inflight_subs
3069 .insert(Ustr::from(format!("dummy:{i}").as_str()));
3070 }
3071
3072 cmd_tx
3073 .send(HandlerCommand::Subscribe {
3074 channel: LighterWsChannel::Trade(0),
3075 auth: None,
3076 })
3077 .expect("queue subscribe");
3078 cmd_tx
3079 .send(HandlerCommand::Unsubscribe {
3080 channel: LighterWsChannel::Trade(0),
3081 })
3082 .expect("queue unsubscribe");
3083 drop(cmd_tx);
3084 drop(raw_tx);
3085
3086 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3087 .await
3088 .expect("timed out waiting for handler to drain commands");
3089
3090 assert!(next.is_none());
3091 assert!(handler.pending_subs.is_empty());
3092 }
3093
3094 #[tokio::test]
3095 async fn reconnect_clears_gate_state() {
3096 let signal = Arc::new(AtomicBool::new(false));
3097 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3098 let (raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3099 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3100 let mut handler =
3101 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3102
3103 for i in 0..SUBSCRIBE_INFLIGHT_MAX {
3105 handler
3106 .inflight_subs
3107 .insert(Ustr::from(format!("dummy:{i}").as_str()));
3108 }
3109 handler
3110 .pending_subs
3111 .push_back((LighterWsChannel::Trade(0), None));
3112
3113 raw_tx
3114 .send(Message::Text(RECONNECTED.to_string().into()))
3115 .expect("queue reconnect sentinel");
3116
3117 let next = tokio::time::timeout(Duration::from_secs(2), handler.next())
3118 .await
3119 .expect("timed out waiting for reconnect");
3120
3121 assert!(matches!(next, Some(NautilusWsMessage::Reconnected)));
3122 assert!(handler.inflight_subs.is_empty());
3123 assert!(handler.pending_subs.is_empty());
3124 }
3125
3126 #[tokio::test]
3127 async fn pump_releases_inflight_slot_on_send_failure() {
3128 let signal = Arc::new(AtomicBool::new(false));
3129 let (_cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
3130 let (_raw_tx, raw_rx) = tokio::sync::mpsc::unbounded_channel::<Message>();
3131 let (out_tx, _out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
3132 let mut handler =
3133 FeedHandler::new(signal, cmd_rx, raw_rx, out_tx, SubscriptionState::new(':'));
3134
3135 for market_index in 0..3 {
3137 handler
3138 .pending_subs
3139 .push_back((LighterWsChannel::Trade(market_index), None));
3140 }
3141
3142 handler.pump_pending_subscribes().await;
3143
3144 assert!(handler.pending_subs.is_empty());
3145 assert!(handler.inflight_subs.is_empty());
3146 }
3147
3148 #[rstest]
3149 fn subscribed_control_frame_releases_inflight_slot() {
3150 let mut handler = make_handler_with_account();
3151 handler.inflight_subs.insert(Ustr::from("candle:0:1m"));
3152
3153 let (matched, msg) =
3154 handler.handle_control_text(r#"{"type":"subscribed","channel":"candle:0:1m"}"#);
3155
3156 assert!(matched);
3157 assert!(msg.is_none());
3158 assert!(!handler.inflight_subs.contains(&Ustr::from("candle:0:1m")));
3159 }
3160
3161 #[rstest]
3162 fn typed_frame_releases_inflight_slot() {
3163 let mut handler = make_handler_with_account();
3164 handler.inflight_subs.insert(Ustr::from("candle:0:1m"));
3165
3166 handler.handle_frame(
3167 candle_frame(
3168 "candle:0:1m",
3169 stub_candle(1_000_000, 10_000, 10_000, 10_000, 10_000, 10_000),
3170 true,
3171 ),
3172 UnixNanos::from(1),
3173 );
3174
3175 assert!(!handler.inflight_subs.contains(&Ustr::from("candle:0:1m")));
3176 }
3177
3178 #[rstest]
3179 fn release_subscribe_inflight_reports_first_ack_only() {
3180 let mut handler = make_handler_with_account();
3181 handler.inflight_subs.insert(Ustr::from("trade:7"));
3182
3183 assert!(handler.release_subscribe_inflight("trade:7"));
3184 assert!(!handler.release_subscribe_inflight("trade:7"));
3185 assert!(!handler.release_subscribe_inflight("never:1"));
3186 }
3187}