1use std::{
19 fmt::Debug,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, AtomicU8, Ordering},
23 },
24 time::Duration,
25};
26
27use arc_swap::ArcSwap;
28use dashmap::DashMap;
29use nautilus_common::live::get_runtime;
30use nautilus_model::{
31 identifiers::{AccountId, InstrumentId},
32 instruments::InstrumentAny,
33};
34use nautilus_network::{
35 mode::ConnectionMode,
36 websocket::{
37 SubscriptionState, TransportBackend, WebSocketClient, WebSocketConfig,
38 channel_message_handler,
39 },
40};
41
42use crate::{
43 common::{
44 consts::{HEARTBEAT_INTERVAL, RECONNECT_BASE_BACKOFF, RECONNECT_MAX_BACKOFF},
45 enums::{LighterCandleResolution, LighterEnvironment},
46 rate_limit::ws_message_rate_limiter,
47 symbol::MarketRegistry,
48 urls::lighter_ws_url,
49 },
50 websocket::{
51 error::LighterWsError,
52 handler::{FeedHandler, HandlerCommand},
53 messages::{LighterMarketSelection, LighterWsChannel, NautilusWsMessage},
54 },
55};
56
57const RECONNECT_TIMEOUT_MS: u64 = 15_000;
58const RECONNECT_JITTER_MS: u64 = 200;
59const RECONNECT_BACKOFF_FACTOR: f64 = 2.0;
60const DISCONNECT_TIMEOUT: Duration = Duration::from_secs(2);
61
62pub struct LighterWebSocketClient {
76 url: String,
77 connection_mode: Arc<ArcSwap<AtomicU8>>,
78 signal: Arc<AtomicBool>,
79 cmd_tx: Arc<tokio::sync::RwLock<tokio::sync::mpsc::UnboundedSender<HandlerCommand>>>,
80 out_rx: Option<tokio::sync::mpsc::UnboundedReceiver<NautilusWsMessage>>,
81 subscriptions: SubscriptionState,
82 subscription_args: Arc<DashMap<String, (LighterWsChannel, Option<String>)>>,
83 instruments: Arc<DashMap<i16, InstrumentAny>>,
84 registry: Arc<MarketRegistry>,
85 task_handle: Option<tokio::task::JoinHandle<()>>,
86 transport_backend: TransportBackend,
87 proxy_url: Option<String>,
88}
89
90impl Debug for LighterWebSocketClient {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 let subscription_topics: Vec<String> = self
98 .subscription_args
99 .iter()
100 .map(|entry| {
101 let (channel, auth) = entry.value();
102 format!(
103 "topic={} channel={:?} authed={}",
104 entry.key(),
105 channel,
106 auth.is_some(),
107 )
108 })
109 .collect();
110
111 f.debug_struct(stringify!(LighterWebSocketClient))
112 .field("url", &self.url)
113 .field("is_active", &self.is_active())
114 .field("subscription_count", &self.subscriptions.len())
115 .field("subscription_args", &subscription_topics)
116 .field("instruments_len", &self.instruments.len())
117 .field("transport_backend", &self.transport_backend)
118 .field("proxy_url", &self.proxy_url)
119 .finish_non_exhaustive()
120 }
121}
122
123impl Clone for LighterWebSocketClient {
124 fn clone(&self) -> Self {
125 Self {
126 url: self.url.clone(),
127 connection_mode: Arc::clone(&self.connection_mode),
128 signal: Arc::clone(&self.signal),
129 cmd_tx: Arc::clone(&self.cmd_tx),
130 out_rx: None,
131 subscriptions: self.subscriptions.clone(),
132 subscription_args: Arc::clone(&self.subscription_args),
133 instruments: Arc::clone(&self.instruments),
134 registry: Arc::clone(&self.registry),
135 task_handle: None,
136 transport_backend: self.transport_backend,
137 proxy_url: self.proxy_url.clone(),
138 }
139 }
140}
141
142impl LighterWebSocketClient {
143 #[must_use]
147 pub fn new(
148 url: Option<String>,
149 environment: LighterEnvironment,
150 registry: Arc<MarketRegistry>,
151 transport_backend: TransportBackend,
152 proxy_url: Option<String>,
153 ) -> Self {
154 let url = url.unwrap_or_else(|| lighter_ws_url(environment).to_string());
155 let connection_mode = Arc::new(ArcSwap::new(Arc::new(AtomicU8::new(
156 ConnectionMode::Closed as u8,
157 ))));
158
159 let (placeholder_tx, _) = tokio::sync::mpsc::unbounded_channel();
160
161 Self {
162 url,
163 connection_mode,
164 signal: Arc::new(AtomicBool::new(false)),
165 cmd_tx: Arc::new(tokio::sync::RwLock::new(placeholder_tx)),
166 out_rx: None,
167 subscriptions: SubscriptionState::new(':'),
168 subscription_args: Arc::new(DashMap::new()),
169 instruments: Arc::new(DashMap::new()),
170 registry,
171 task_handle: None,
172 transport_backend,
173 proxy_url,
174 }
175 }
176
177 #[must_use]
179 pub fn url(&self) -> &str {
180 &self.url
181 }
182
183 #[must_use]
185 pub fn is_active(&self) -> bool {
186 self.connection_mode.load().load(Ordering::Relaxed) == ConnectionMode::Active as u8
187 }
188
189 pub async fn wait_until_active(&self, timeout_secs: f64) -> Result<(), LighterWsError> {
201 let timeout = Duration::from_secs_f64(timeout_secs);
202
203 tokio::time::timeout(timeout, async {
204 while !self.is_active() {
205 tokio::time::sleep(Duration::from_millis(10)).await;
206 }
207 })
208 .await
209 .map_err(|_| {
210 LighterWsError::Client(format!(
211 "WebSocket connection timeout after {timeout_secs} seconds"
212 ))
213 })
214 }
215
216 #[must_use]
218 pub fn subscription_count(&self) -> usize {
219 self.subscriptions.len()
220 }
221
222 #[must_use]
224 pub fn instruments_cache(&self) -> Arc<DashMap<i16, InstrumentAny>> {
225 Arc::clone(&self.instruments)
226 }
227
228 pub fn cache_instruments(&self, instruments: Vec<(i16, InstrumentAny)>) {
231 self.instruments.clear();
232 for (market_index, instrument) in &instruments {
233 self.instruments.insert(*market_index, instrument.clone());
234 }
235 log::debug!(
236 "Lighter instrument cache initialized with {} instruments",
237 instruments.len()
238 );
239
240 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
241 let _ = cmd_tx.send(HandlerCommand::InitializeInstruments(instruments));
242 }
243 }
244
245 pub fn cache_instrument(&self, market_index: i16, instrument: InstrumentAny) {
247 self.instruments.insert(market_index, instrument.clone());
248
249 if let Ok(cmd_tx) = self.cmd_tx.try_read() {
250 let _ = cmd_tx.send(HandlerCommand::UpdateInstrument {
251 market_index,
252 instrument,
253 });
254 }
255 }
256
257 pub async fn connect(&mut self) -> anyhow::Result<()> {
264 if self.is_active() {
265 log::warn!("Lighter WebSocket already connected");
266 return Ok(());
267 }
268
269 let (message_handler, raw_rx) = channel_message_handler();
270 let cfg = WebSocketConfig {
271 url: self.url.clone(),
272 headers: vec![],
273 heartbeat: Some(HEARTBEAT_INTERVAL.as_secs()),
274 heartbeat_msg: None,
275 reconnect_timeout_ms: Some(RECONNECT_TIMEOUT_MS),
276 reconnect_delay_initial_ms: Some(RECONNECT_BASE_BACKOFF.as_millis() as u64),
277 reconnect_delay_max_ms: Some(RECONNECT_MAX_BACKOFF.as_millis() as u64),
278 reconnect_backoff_factor: Some(RECONNECT_BACKOFF_FACTOR),
279 reconnect_jitter_ms: Some(RECONNECT_JITTER_MS),
280 reconnect_max_attempts: None,
281 idle_timeout_ms: None,
282 backend: self.transport_backend,
283 proxy_url: self.proxy_url.clone(),
284 };
285 let client = WebSocketClient::connect_with_rate_limiter(
286 cfg,
287 Some(message_handler),
288 None,
289 None,
290 ws_message_rate_limiter(&self.url),
291 )
292 .await?;
293
294 let (cmd_tx, cmd_rx) = tokio::sync::mpsc::unbounded_channel::<HandlerCommand>();
295 let (out_tx, out_rx) = tokio::sync::mpsc::unbounded_channel::<NautilusWsMessage>();
296
297 let connection_mode_atomic = client.connection_mode_atomic();
300
301 if let Err(e) = cmd_tx.send(HandlerCommand::SetClient(client)) {
307 anyhow::bail!("Failed to send SetClient command: {e}");
308 }
309
310 let initial_instruments: Vec<(i16, InstrumentAny)> = self
311 .instruments
312 .iter()
313 .map(|entry| (*entry.key(), entry.value().clone()))
314 .collect();
315
316 if !initial_instruments.is_empty()
317 && let Err(e) = cmd_tx.send(HandlerCommand::InitializeInstruments(initial_instruments))
318 {
319 log::error!("Failed to send InitializeInstruments: {e}");
320 }
321
322 *self.cmd_tx.write().await = cmd_tx.clone();
326 self.out_rx = Some(out_rx);
327 self.connection_mode.store(connection_mode_atomic);
328
329 log::debug!("Lighter WebSocket connected: {}", self.url);
330
331 let signal = Arc::clone(&self.signal);
332 let subscriptions = self.subscriptions.clone();
333 let subscription_args = Arc::clone(&self.subscription_args);
334 let cmd_tx_for_reconnect = cmd_tx.clone();
335
336 let task = get_runtime().spawn(async move {
337 let mut handler =
338 FeedHandler::new(Arc::clone(&signal), cmd_rx, raw_rx, out_tx, subscriptions);
339 handler.set_command_sender(cmd_tx_for_reconnect.clone());
340
341 let restore_subscriptions = || {
342 if subscription_args.is_empty() {
343 log::debug!("No active Lighter subscriptions to restore after reconnect");
344 return;
345 }
346 log::debug!(
347 "Restoring {} Lighter subscriptions after reconnect",
348 subscription_args.len(),
349 );
350
351 for entry in subscription_args.iter() {
353 let (channel, auth) = entry.value().clone();
354 if let Err(e) =
355 cmd_tx_for_reconnect.send(HandlerCommand::Subscribe { channel, auth })
356 {
357 log::error!("Failed to resend Lighter subscribe command: {e}");
358 }
359 }
360 };
361
362 loop {
363 match handler.next().await {
364 Some(NautilusWsMessage::Reconnected) => {
365 log::debug!("Lighter WebSocket reconnected");
366 restore_subscriptions();
367
368 if handler.send(NautilusWsMessage::Reconnected).is_err() {
369 if handler.is_stopped() {
370 log::debug!("Failed to forward Reconnected (receiver dropped)");
371 } else {
372 log::error!("Failed to forward Reconnected (receiver dropped)");
373 }
374 break;
375 }
376 }
377 Some(msg) => {
378 if handler.send(msg).is_err() {
379 if handler.is_stopped() {
380 log::debug!("Failed to send Lighter message (receiver dropped)");
381 } else {
382 log::error!("Failed to send Lighter message (receiver dropped)");
383 }
384 break;
385 }
386 }
387 None => {
388 if handler.is_stopped() {
389 log::debug!("Lighter handler stop signal observed, exiting loop");
390 break;
391 }
392 log::warn!("Lighter WebSocket stream ended unexpectedly");
393 break;
394 }
395 }
396 }
397 log::debug!("Lighter handler task completed");
398 });
399 self.task_handle = Some(task);
400 Ok(())
401 }
402
403 pub async fn disconnect(&mut self) -> Result<(), LighterWsError> {
410 log::debug!("Disconnecting Lighter WebSocket");
411
412 if let Err(e) = self.cmd_tx.read().await.send(HandlerCommand::Disconnect) {
413 log::debug!("Failed to send Lighter disconnect command: {e}");
414 }
415 self.signal.store(true, Ordering::Release);
416
417 if let Some(handle) = self.task_handle.take() {
418 let abort_handle = handle.abort_handle();
419 tokio::select! {
420 result = handle => match result {
421 Ok(()) => log::debug!("Lighter handler task completed"),
422 Err(e) if e.is_cancelled() => log::debug!("Lighter handler task cancelled"),
423 Err(e) => log::error!("Lighter handler task error: {e:?}"),
424 },
425 () = tokio::time::sleep(DISCONNECT_TIMEOUT) => {
426 log::warn!("Timeout waiting for Lighter handler task, aborting");
427 abort_handle.abort();
428 }
429 }
430 }
431
432 self.connection_mode
433 .store(Arc::new(AtomicU8::new(ConnectionMode::Closed as u8)));
434 Ok(())
435 }
436
437 pub async fn next_event(&mut self) -> Option<NautilusWsMessage> {
440 if let Some(rx) = self.out_rx.as_mut() {
441 rx.recv().await
442 } else {
443 None
444 }
445 }
446
447 #[must_use]
452 pub fn take_task_handle(&mut self) -> Option<tokio::task::JoinHandle<()>> {
453 self.task_handle.take()
454 }
455
456 pub fn set_task_handle(&mut self, handle: tokio::task::JoinHandle<()>) {
459 self.task_handle = Some(handle);
460 }
461
462 pub async fn subscribe_book(&self, instrument_id: InstrumentId) -> Result<(), LighterWsError> {
469 let market_index = self.market_index_for(&instrument_id)?;
470 self.send_cmd(HandlerCommand::SetBookDeltasSub {
471 market_index,
472 subscribed: true,
473 })
474 .await?;
475
476 if let Err(e) = self.subscribe_order_book_stream(market_index).await {
477 let _ = self
478 .send_cmd(HandlerCommand::SetBookDeltasSub {
479 market_index,
480 subscribed: false,
481 })
482 .await;
483 return Err(e);
484 }
485
486 Ok(())
487 }
488
489 pub async fn unsubscribe_book(
496 &self,
497 instrument_id: InstrumentId,
498 ) -> Result<(), LighterWsError> {
499 let market_index = self.market_index_for(&instrument_id)?;
500 self.send_cmd(HandlerCommand::SetBookDeltasSub {
501 market_index,
502 subscribed: false,
503 })
504 .await?;
505 self.unsubscribe_order_book_stream(market_index).await
506 }
507
508 pub async fn subscribe_book_depth10(
516 &self,
517 instrument_id: InstrumentId,
518 ) -> Result<(), LighterWsError> {
519 let market_index = self.market_index_for(&instrument_id)?;
520 self.send_cmd(HandlerCommand::SetDepth10Sub {
521 market_index,
522 subscribed: true,
523 })
524 .await?;
525
526 if let Err(e) = self.subscribe_order_book_stream(market_index).await {
527 let _ = self
528 .send_cmd(HandlerCommand::SetDepth10Sub {
529 market_index,
530 subscribed: false,
531 })
532 .await;
533 return Err(e);
534 }
535
536 Ok(())
537 }
538
539 pub async fn unsubscribe_book_depth10(
550 &self,
551 instrument_id: InstrumentId,
552 ) -> Result<(), LighterWsError> {
553 let market_index = self.market_index_for(&instrument_id)?;
554 self.send_cmd(HandlerCommand::SetDepth10Sub {
555 market_index,
556 subscribed: false,
557 })
558 .await?;
559 self.unsubscribe_order_book_stream(market_index).await
560 }
561
562 pub async fn subscribe_quotes(
569 &self,
570 instrument_id: InstrumentId,
571 ) -> Result<(), LighterWsError> {
572 let market_index = self.market_index_for(&instrument_id)?;
573 self.send_subscribe(LighterWsChannel::Ticker(market_index), None)
574 .await
575 }
576
577 pub async fn unsubscribe_quotes(
584 &self,
585 instrument_id: InstrumentId,
586 ) -> Result<(), LighterWsError> {
587 let market_index = self.market_index_for(&instrument_id)?;
588 self.send_unsubscribe(LighterWsChannel::Ticker(market_index))
589 .await
590 }
591
592 pub async fn subscribe_trades(
599 &self,
600 instrument_id: InstrumentId,
601 ) -> Result<(), LighterWsError> {
602 let market_index = self.market_index_for(&instrument_id)?;
603 self.send_subscribe(LighterWsChannel::Trade(market_index), None)
604 .await
605 }
606
607 pub async fn unsubscribe_trades(
614 &self,
615 instrument_id: InstrumentId,
616 ) -> Result<(), LighterWsError> {
617 let market_index = self.market_index_for(&instrument_id)?;
618 self.send_unsubscribe(LighterWsChannel::Trade(market_index))
619 .await
620 }
621
622 pub async fn subscribe_candles(
631 &self,
632 instrument_id: InstrumentId,
633 resolution: LighterCandleResolution,
634 ) -> Result<(), LighterWsError> {
635 if !resolution.is_ws_streamable() {
636 return Err(LighterWsError::Client(format!(
637 "resolution {resolution:?} is not offered on the Lighter candle WebSocket stream",
638 )));
639 }
640 let market_index = self.market_index_for(&instrument_id)?;
641 self.send_subscribe(
642 LighterWsChannel::Candle {
643 market_index,
644 resolution,
645 },
646 None,
647 )
648 .await
649 }
650
651 pub async fn unsubscribe_candles(
658 &self,
659 instrument_id: InstrumentId,
660 resolution: LighterCandleResolution,
661 ) -> Result<(), LighterWsError> {
662 let market_index = self.market_index_for(&instrument_id)?;
663 self.send_unsubscribe(LighterWsChannel::Candle {
664 market_index,
665 resolution,
666 })
667 .await
668 }
669
670 pub async fn subscribe_market_stats(
677 &self,
678 selection: LighterMarketSelection,
679 ) -> Result<(), LighterWsError> {
680 self.send_subscribe(LighterWsChannel::MarketStats(selection), None)
681 .await
682 }
683
684 pub async fn unsubscribe_market_stats(
690 &self,
691 selection: LighterMarketSelection,
692 ) -> Result<(), LighterWsError> {
693 self.send_unsubscribe(LighterWsChannel::MarketStats(selection))
694 .await
695 }
696
697 pub async fn subscribe_spot_market_stats(
704 &self,
705 selection: LighterMarketSelection,
706 ) -> Result<(), LighterWsError> {
707 self.send_subscribe(LighterWsChannel::SpotMarketStats(selection), None)
708 .await
709 }
710
711 pub async fn unsubscribe_spot_market_stats(
717 &self,
718 selection: LighterMarketSelection,
719 ) -> Result<(), LighterWsError> {
720 self.send_unsubscribe(LighterWsChannel::SpotMarketStats(selection))
721 .await
722 }
723
724 pub async fn subscribe_height(&self) -> Result<(), LighterWsError> {
730 self.send_subscribe(LighterWsChannel::Height, None).await
731 }
732
733 pub async fn unsubscribe_height(&self) -> Result<(), LighterWsError> {
739 self.send_unsubscribe(LighterWsChannel::Height).await
740 }
741
742 pub async fn set_execution_context(
757 &self,
758 account_id: AccountId,
759 account_index: i64,
760 ) -> Result<(), LighterWsError> {
761 self.send_cmd(HandlerCommand::SetExecutionContext {
762 account_id,
763 account_index,
764 })
765 .await
766 }
767
768 pub async fn subscribe_account(
779 &self,
780 channel: LighterWsChannel,
781 auth_token: String,
782 ) -> Result<(), LighterWsError> {
783 self.send_subscribe(channel, Some(auth_token)).await
784 }
785
786 pub async fn unsubscribe_account(
792 &self,
793 channel: LighterWsChannel,
794 ) -> Result<(), LighterWsError> {
795 self.send_unsubscribe(channel).await
796 }
797
798 pub async fn send_tx(
809 &self,
810 tx_type: u8,
811 tx_info: Box<serde_json::value::RawValue>,
812 ) -> Result<(), LighterWsError> {
813 let (response_tx, response_rx) = tokio::sync::oneshot::channel();
814 self.send_cmd(HandlerCommand::SendTx {
815 tx_type,
816 tx_info,
817 response_tx,
818 })
819 .await?;
820
821 response_rx
822 .await
823 .map_err(|e| LighterWsError::Client(format!("handler dropped sendTx result: {e}")))?
824 }
825
826 async fn send_subscribe(
827 &self,
828 channel: LighterWsChannel,
829 auth: Option<String>,
830 ) -> Result<(), LighterWsError> {
831 let topic = channel.topic_key();
832 let previous = self
833 .subscription_args
834 .insert(topic.clone(), (channel.clone(), auth.clone()));
835 if let Err(e) = self
836 .send_cmd(HandlerCommand::Subscribe { channel, auth })
837 .await
838 {
839 if let Some(previous) = previous {
840 self.subscription_args.insert(topic, previous);
841 } else {
842 self.subscription_args.remove(&topic);
843 }
844 return Err(e);
845 }
846
847 Ok(())
848 }
849
850 async fn send_unsubscribe(&self, channel: LighterWsChannel) -> Result<(), LighterWsError> {
851 let topic = channel.topic_key();
852 self.send_cmd(HandlerCommand::Unsubscribe { channel })
853 .await?;
854 self.subscription_args.remove(&topic);
855 Ok(())
856 }
857
858 async fn subscribe_order_book_stream(&self, market_index: i16) -> Result<(), LighterWsError> {
859 let channel = LighterWsChannel::OrderBook(market_index);
860 let topic = channel.topic_key();
861
862 if !self.subscriptions.add_reference(topic.as_str()) {
863 return Ok(());
864 }
865
866 if let Err(e) = self.send_subscribe(channel, None).await {
867 self.subscriptions.remove_reference(topic.as_str());
868 return Err(e);
869 }
870
871 Ok(())
872 }
873
874 async fn unsubscribe_order_book_stream(&self, market_index: i16) -> Result<(), LighterWsError> {
875 let channel = LighterWsChannel::OrderBook(market_index);
876 let topic = channel.topic_key();
877
878 if !self.subscriptions.remove_reference(topic.as_str()) {
879 return Ok(());
880 }
881
882 if let Err(e) = self.send_unsubscribe(channel).await {
883 self.subscriptions.add_reference(topic.as_str());
884 return Err(e);
885 }
886
887 Ok(())
888 }
889
890 async fn send_cmd(&self, cmd: HandlerCommand) -> Result<(), LighterWsError> {
891 self.cmd_tx
892 .read()
893 .await
894 .send(cmd)
895 .map_err(|e| LighterWsError::Client(format!("handler unavailable: {e}")))
896 }
897
898 fn market_index_for(&self, instrument_id: &InstrumentId) -> Result<i16, LighterWsError> {
899 self.registry.market_index(instrument_id).ok_or_else(|| {
900 LighterWsError::Client(format!(
901 "no Lighter market_index registered for instrument: {instrument_id}"
902 ))
903 })
904 }
905}
906
907#[cfg(test)]
908mod tests {
909 use nautilus_core::UnixNanos;
910 use nautilus_model::{
911 identifiers::Symbol,
912 instruments::CryptoPerpetual,
913 types::{Currency, Price, Quantity},
914 };
915 use rstest::rstest;
916
917 use super::*;
918 use crate::common::{consts::LIGHTER_VENUE, enums::LighterProductType};
919
920 fn registry_with(
921 market_index: i16,
922 symbol: &str,
923 product: LighterProductType,
924 ) -> Arc<MarketRegistry> {
925 let registry = Arc::new(MarketRegistry::new());
926 registry.insert(market_index, symbol, product);
927 registry
928 }
929
930 #[rstest]
931 fn market_index_for_returns_registered_index() {
932 let registry = registry_with(7, "ETH", LighterProductType::Perp);
933 let client = LighterWebSocketClient::new(
934 Some("wss://example/test".to_string()),
935 LighterEnvironment::Testnet,
936 Arc::clone(®istry),
937 TransportBackend::default(),
938 None,
939 );
940 let id = registry.instrument_id(7).expect("registered");
941 assert_eq!(client.market_index_for(&id).unwrap(), 7);
942 }
943
944 #[rstest]
945 fn market_index_for_unregistered_returns_error() {
946 let registry = Arc::new(MarketRegistry::new());
947 let client = LighterWebSocketClient::new(
948 Some("wss://example/test".to_string()),
949 LighterEnvironment::Testnet,
950 registry,
951 TransportBackend::default(),
952 None,
953 );
954 let id = InstrumentId::new(Symbol::from_str_unchecked("UNKNOWN-PERP"), *LIGHTER_VENUE);
955 assert!(client.market_index_for(&id).is_err());
956 }
957
958 #[rstest]
959 fn cache_instrument_populates_lookup() {
960 let registry = registry_with(0, "ETH", LighterProductType::Perp);
961 let client = LighterWebSocketClient::new(
962 Some("wss://example/test".to_string()),
963 LighterEnvironment::Testnet,
964 Arc::clone(®istry),
965 TransportBackend::default(),
966 None,
967 );
968 let id = registry.instrument_id(0).expect("registered");
969 let instrument = stub_instrument(id);
970 client.cache_instrument(0, instrument);
971 assert!(client.instruments_cache().contains_key(&0));
972 }
973
974 fn stub_instrument(id: InstrumentId) -> InstrumentAny {
975 InstrumentAny::CryptoPerpetual(CryptoPerpetual::new(
976 id,
977 id.symbol,
978 Currency::from("ETH"),
979 Currency::from("USDC"),
980 Currency::from("USDC"),
981 false,
982 2,
983 4,
984 Price::from("0.01"),
985 Quantity::from("0.0001"),
986 None,
987 None,
988 None,
989 None,
990 None,
991 None,
992 None,
993 None,
994 None,
995 None,
996 None,
997 None,
998 None,
999 None,
1000 UnixNanos::default(),
1001 UnixNanos::default(),
1002 ))
1003 }
1004}