1use std::{
19 future::Future,
20 sync::{
21 Arc,
22 atomic::{AtomicBool, Ordering},
23 },
24 time::Duration,
25};
26
27use ahash::{AHashMap, AHashSet};
28use anyhow::Context;
29use async_trait::async_trait;
30use futures_util::StreamExt;
31use jiff::{SignedDuration, Timestamp};
32use nautilus_common::{
33 clients::{DataClient, execute_bar_subscriptions, execute_instrument_subscriptions},
34 live::{dst::time::Instant, runner::get_data_event_sender, sender::EventSender},
35 messages::{
36 DataEvent, DataResponse,
37 data::{
38 BarsResponse, BookResponse, FundingRatesResponse, InstrumentResponse,
39 InstrumentsResponse, RequestBars, RequestBookSnapshot, RequestFundingRates,
40 RequestInstrument, RequestInstruments, RequestTrades, SubscribeBars,
41 SubscribeBookDeltas, SubscribeFundingRates, SubscribeIndexPrices, SubscribeInstrument,
42 SubscribeInstrumentClose, SubscribeInstrumentStatus, SubscribeInstruments,
43 SubscribeMarkPrices, SubscribeQuotes, SubscribeTrades, TradesResponse, UnsubscribeBars,
44 UnsubscribeBookDeltas, UnsubscribeFundingRates, UnsubscribeIndexPrices,
45 UnsubscribeInstrument, UnsubscribeInstrumentClose, UnsubscribeInstrumentStatus,
46 UnsubscribeInstruments, UnsubscribeMarkPrices, UnsubscribeQuotes, UnsubscribeTrades,
47 },
48 },
49};
50use nautilus_core::{
51 AtomicMap,
52 datetime::datetime_to_unix_nanos,
53 nanos::UnixNanos,
54 time::{AtomicTime, get_atomic_clock_realtime},
55};
56use nautilus_live::{
57 SocketControl,
58 task::{TaskGroup, TaskGroupGuard, TaskSpawner},
59};
60use nautilus_model::{
61 data::{Data, FundingRateUpdate, InstrumentStatus, MarkPriceUpdate, OrderBookDeltas},
62 enums::{BookType, MarketStatusAction},
63 identifiers::{ClientId, InstrumentId, Venue},
64 instruments::{Instrument, InstrumentAny},
65 types::Price,
66};
67use parking_lot::Mutex;
68use tokio_util::sync::CancellationToken;
69use ustr::Ustr;
70
71use crate::{
72 book::{self, sync::BookSyncTracker},
73 common::{
74 auth::run_auth_token_refresh,
75 consts::{AX_AUTH_TOKEN_TTL_SECS, AX_FUNDING_RATE_LOOKBACK_DAYS, AX_VENUE},
76 credential::Credential,
77 enums::{AxCandleWidth, AxInstrumentState, AxMarketDataLevel},
78 parse::{ax_timestamp_stn_to_unix_nanos, map_bar_spec_to_candle_width},
79 },
80 config::AxDataClientConfig,
81 http::client::AxHttpClient,
82 websocket::{
83 data::{
84 client::{AxMdWebSocketClient, AxWsClientError, SymbolDataTypes},
85 parse::{
86 parse_book_l1_quote, parse_book_l2_deltas, parse_book_l2_quote,
87 parse_book_l3_deltas, parse_book_l3_quote, parse_candle_bar, parse_trade_tick,
88 },
89 subscription::SubscriptionOrder,
90 },
91 messages::{AxDataWsMessage, AxMdCandle, AxMdMessage},
92 },
93};
94
95#[derive(Debug)]
103pub struct AxDataClient {
104 client_id: ClientId,
105 config: AxDataClientConfig,
106 http_client: AxHttpClient,
107 ws_client: AxMdWebSocketClient,
108 is_connected: Arc<AtomicBool>,
109 cancellation_token: CancellationToken,
110 session_tasks: TaskGroup,
111 pending_tasks: TaskGroup,
112 subscription_order: SubscriptionOrder,
113 shutdown_errors: Vec<String>,
114 data_sender: EventSender<DataEvent>,
115 instruments: Arc<AtomicMap<Ustr, InstrumentAny>>,
116 clock: &'static AtomicTime,
117 funding_rate_cancellations: AHashMap<InstrumentId, CancellationToken>,
118 funding_rate_cache: Arc<Mutex<AHashMap<InstrumentId, FundingRateUpdate>>>,
119 book_sync: BookSyncTracker,
120}
121
122impl AxDataClient {
123 pub fn new(
129 client_id: ClientId,
130 config: AxDataClientConfig,
131 http_client: AxHttpClient,
132 ws_client: AxMdWebSocketClient,
133 ) -> anyhow::Result<Self> {
134 let clock = get_atomic_clock_realtime();
135 let data_sender = get_data_event_sender();
136 let ws_client = ws_client.with_socket_control(SocketControl::new(
137 client_id,
138 Some(*AX_VENUE),
139 "architect-ax-data-streams",
140 ));
141
142 let instruments = http_client.instruments_cache.clone();
144
145 let session_tasks = TaskGroup::new();
146 let pending_tasks = TaskGroup::new();
147
148 Ok(Self {
149 client_id,
150 config,
151 http_client,
152 ws_client,
153 is_connected: Arc::new(AtomicBool::new(false)),
154 cancellation_token: CancellationToken::new(),
155 session_tasks,
156 pending_tasks,
157 subscription_order: SubscriptionOrder::default(),
158 shutdown_errors: Vec::new(),
159 data_sender,
160 instruments,
161 clock,
162 funding_rate_cancellations: AHashMap::new(),
163 funding_rate_cache: Arc::new(Mutex::new(AHashMap::new())),
164 book_sync: BookSyncTracker::default(),
165 })
166 }
167
168 #[must_use]
170 pub fn venue(&self) -> Venue {
171 *AX_VENUE
172 }
173
174 fn map_book_type_to_market_data_level(book_type: BookType) -> AxMarketDataLevel {
175 match book_type {
176 BookType::L3_MBO => AxMarketDataLevel::Level3,
177 BookType::L1_MBP | BookType::L2_MBP => AxMarketDataLevel::Level2,
178 }
179 }
180
181 #[must_use]
183 pub fn instruments(&self) -> &Arc<AtomicMap<Ustr, InstrumentAny>> {
184 &self.instruments
185 }
186
187 fn spawn_message_handler(&mut self) -> anyhow::Result<()> {
189 let stream = self.ws_client.stream();
190 let data_sender = self.data_sender.clone();
191 let cancellation_token = self.cancellation_token.clone();
192 let is_connected = Arc::clone(&self.is_connected);
193 let instruments = Arc::clone(&self.instruments);
194 let symbol_data_types = self.ws_client.symbol_data_types();
195 let status_invalidations = self.ws_client.status_invalidations();
196 let clock = self.clock;
197 let book_sync = self.book_sync.clone();
198 let ws_client = self.ws_client.clone();
199 let snapshot_timeout = self.book_snapshot_timeout();
200 let book_tasks = self.pending_tasks.spawner()?;
201
202 self.session_tasks.spawn(async move {
203 tokio::pin!(stream);
204
205 let mut book_sequences: AHashMap<Ustr, u64> = AHashMap::new();
206 let mut candle_cache: AHashMap<(Ustr, AxCandleWidth), AxMdCandle> = AHashMap::new();
207 let mut instrument_states: AHashMap<Ustr, AxInstrumentState> = AHashMap::new();
208
209 loop {
210 tokio::select! {
211 () = cancellation_token.cancelled() => {
212 log::debug!("Message handler cancelled");
213 break;
214 }
215 msg = stream.next() => {
216 match msg {
217 Some(ws_msg) => {
218 drain_status_invalidations(
219 &status_invalidations,
220 &mut instrument_states,
221 );
222
223 handle_ws_message(
224 ws_msg,
225 &data_sender,
226 &instruments,
227 &symbol_data_types,
228 &mut book_sequences,
229 &mut candle_cache,
230 &mut instrument_states,
231 clock,
232 &book_sync,
233 &ws_client,
234 snapshot_timeout,
235 &book_tasks,
236 );
237 }
238 None => {
239 log::debug!("WebSocket stream ended");
240 is_connected.store(false, Ordering::Release);
241 break;
242 }
243 }
244 }
245 }
246 }
247 })?;
248 Ok(())
249 }
250
251 fn book_snapshot_timeout(&self) -> Duration {
252 Duration::from_secs(self.config.book_snapshot_timeout_secs)
253 }
254
255 fn spawn_instrument_refresh(&self) -> anyhow::Result<()> {
256 let minutes = self.config.update_instruments_interval_mins;
257 if minutes == 0 {
258 return Ok(());
259 }
260
261 let interval = Duration::from_secs(minutes.saturating_mul(60));
262 let cancellation = self.cancellation_token.clone();
263 let http_client = self.http_client.clone();
264 let data_sender = self.data_sender.clone();
265 let client_id = self.client_id;
266
267 self.session_tasks.spawn(async move {
268 loop {
269 let sleep = tokio::time::sleep(interval);
270 tokio::pin!(sleep);
271 tokio::select! {
272 () = cancellation.cancelled() => {
273 log::debug!("Instrument refresh task cancelled");
274 break;
275 }
276 () = &mut sleep => {
277 match http_client.request_instruments().await {
278 Ok(instruments) => {
279 http_client.cache_instruments(&instruments);
280 for inst in &instruments {
281 if let Err(e) = data_sender
282 .send(DataEvent::Data(inst.clone().into()))
283 {
284 log::warn!("Failed to send refreshed instrument: {e}");
285 }
286 }
287 log::debug!(
288 "Instruments refreshed: client_id={client_id}, count={}",
289 instruments.len(),
290 );
291 }
292 Err(e) => {
293 log::warn!("Failed to refresh instruments: client_id={client_id}, error={e:?}");
294 }
295 }
296 }
297 }
298 }
299 })?;
300 Ok(())
301 }
302
303 #[expect(
304 clippy::unnecessary_wraps,
305 reason = "callers forward Result to trait methods"
306 )]
307 fn ws_symbol_op<F, Fut>(
308 &mut self,
309 instrument_id: InstrumentId,
310 op: F,
311 context: &'static str,
312 ) -> anyhow::Result<()>
313 where
314 F: FnOnce(AxMdWebSocketClient, String) -> Fut + Send + 'static,
315 Fut: Future<Output = Result<(), AxWsClientError>> + Send,
316 {
317 let symbol = instrument_id.symbol.to_string();
318 log::debug!("{context} for {symbol}");
319
320 let ws = self.ws_client.clone();
321 self.spawn_ws(
322 async move { op(ws, symbol).await.map_err(|e| anyhow::anyhow!(e)) },
323 context,
324 );
325
326 Ok(())
327 }
328
329 fn spawn_ws<F>(&mut self, fut: F, context: &'static str)
331 where
332 F: Future<Output = anyhow::Result<()>> + Send + 'static,
333 {
334 let mut turn = self.subscription_order.next();
335
336 let future = async move {
337 turn.wait().await;
338
339 if let Err(e) = fut.await {
340 log::error!("{context}: {e:?}");
341 }
342 };
343
344 if let Err(e) = self.pending_tasks.spawn(future) {
345 log::warn!("Skipping AX {context} after shutdown began: {e}");
346 }
347 }
348
349 fn spawn_task<F>(&self, fut: F)
350 where
351 F: Future<Output = ()> + Send + 'static,
352 {
353 if let Err(e) = self.pending_tasks.spawn(fut) {
354 log::warn!("Skipping AX data task after shutdown began: {e}");
355 }
356 }
357
358 fn abort_pending_tasks(&self) {
359 self.pending_tasks.begin_shutdown();
360 }
361
362 fn abort_all_tasks(&self) {
363 self.cancellation_token.cancel();
364 self.session_tasks.begin_shutdown();
365 self.abort_pending_tasks();
366 self.ws_client.begin_shutdown();
367
368 for cancellation in self.funding_rate_cancellations.values() {
369 cancellation.cancel();
370 }
371 }
372
373 async fn finish_all_tasks(&mut self) -> anyhow::Result<()> {
374 self.pending_tasks.begin_shutdown();
375 self.session_tasks.begin_shutdown();
376 let (pending_result, session_result) = tokio::join!(
377 self.pending_tasks
378 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
379 self.session_tasks
380 .finish_shutdown(Duration::from_secs(1), Duration::from_secs(2)),
381 );
382 self.funding_rate_cancellations.clear();
383
384 pending_result.map_err(|e| anyhow::anyhow!("Failed to terminate AX data tasks: {e}"))?;
385 session_result
386 .map_err(|e| anyhow::anyhow!("Failed to terminate AX data session tasks: {e}"))?;
387 Ok(())
388 }
389
390 async fn teardown_partial_connect(&mut self) -> anyhow::Result<()> {
391 self.abort_all_tasks();
392
393 if let Err(e) = self.ws_client.close().await {
394 self.shutdown_errors.push(e.to_string());
395 }
396
397 if let Err(e) = self.finish_all_tasks().await {
398 self.shutdown_errors.push(e.to_string());
399 }
400 self.is_connected.store(false, Ordering::Release);
401
402 if !self.shutdown_errors.is_empty() {
403 anyhow::bail!(std::mem::take(&mut self.shutdown_errors).join("; "));
404 }
405 Ok(())
406 }
407}
408
409#[async_trait(?Send)]
410impl DataClient for AxDataClient {
411 fn client_id(&self) -> ClientId {
412 self.client_id
413 }
414
415 fn venue(&self) -> Option<Venue> {
416 Some(*AX_VENUE)
417 }
418
419 fn start(&mut self) -> anyhow::Result<()> {
420 log::debug!("Starting {}", self.client_id);
421 Ok(())
422 }
423
424 fn stop(&mut self) -> anyhow::Result<()> {
425 log::debug!("Stopping {}", self.client_id);
426
427 self.abort_all_tasks();
428 self.is_connected.store(false, Ordering::Release);
429 Ok(())
430 }
431
432 fn reset(&mut self) -> anyhow::Result<()> {
433 log::debug!("Resetting {}", self.client_id);
434
435 self.abort_all_tasks();
436 self.is_connected.store(false, Ordering::Release);
437 self.funding_rate_cache.lock().clear();
438 Ok(())
439 }
440
441 fn dispose(&mut self) -> anyhow::Result<()> {
442 log::debug!("Disposing {}", self.client_id);
443
444 self.abort_all_tasks();
445 self.is_connected.store(false, Ordering::Release);
446 Ok(())
447 }
448
449 fn is_connected(&self) -> bool {
450 self.is_connected.load(Ordering::Acquire)
451 }
452
453 fn is_disconnected(&self) -> bool {
454 !self.is_connected()
455 }
456
457 async fn connect(&mut self) -> anyhow::Result<()> {
458 if self.is_connected()
459 && !self.cancellation_token.is_cancelled()
460 && self.pending_tasks.is_open()
461 && self.session_tasks.is_open()
462 {
463 log::debug!("Already connected {}", self.client_id);
464 return Ok(());
465 }
466
467 log::info!("Connecting {}", self.client_id);
468
469 if self.cancellation_token.is_cancelled()
470 || !self.pending_tasks.is_open()
471 || !self.session_tasks.is_open()
472 || !self.funding_rate_cancellations.is_empty()
473 {
474 self.teardown_partial_connect().await?;
475 self.session_tasks
476 .start_generation()
477 .map_err(|e| anyhow::anyhow!("Failed to start AX data session generation: {e}"))?;
478 self.pending_tasks
479 .start_generation()
480 .map_err(|e| anyhow::anyhow!("Failed to start AX data task generation: {e}"))?;
481 self.cancellation_token = CancellationToken::new();
482 }
483 let cancellation_token = self.cancellation_token.clone();
484 let ws_client = self.ws_client.clone();
485 let setup_guard =
486 TaskGroupGuard::new(&[&self.session_tasks, &self.pending_tasks], move || {
487 cancellation_token.cancel();
488 ws_client.begin_shutdown();
489 });
490
491 let credential = if self.config.has_api_credentials() {
492 let credential = Credential::resolve(
493 self.config.api_key.clone().map(|value| value.into_inner()),
494 self.config
495 .api_secret
496 .clone()
497 .map(|value| value.into_inner()),
498 )
499 .context("API credentials not configured")?;
500
501 let token = self
502 .http_client
503 .authenticate(
504 credential.api_key(),
505 credential.api_secret(),
506 AX_AUTH_TOKEN_TTL_SECS,
507 )
508 .await
509 .context("Failed to authenticate with Ax")?;
510 log::debug!("Authenticated with Ax");
511 self.ws_client.set_auth_token(token);
512
513 self.http_client
516 .request_account_fees()
517 .await
518 .context("Failed to resolve Ax account fee rates")?;
519
520 Some(credential)
521 } else {
522 log::debug!("No Ax credentials configured");
523 None
524 };
525
526 let instruments = self
527 .http_client
528 .request_instruments()
529 .await
530 .context("Failed to fetch instruments")?;
531
532 self.http_client.cache_instruments(&instruments);
533 for instrument in &instruments {
534 if let Err(e) = self
535 .data_sender
536 .send(DataEvent::Data(instrument.clone().into()))
537 {
538 log::warn!("Failed to send instrument: {e}");
539 }
540 }
541
542 log::debug!(
543 "Cached {} instruments",
544 self.http_client.get_cached_symbols().len()
545 );
546
547 self.ws_client
548 .connect()
549 .await
550 .context("Failed to connect WebSocket")?;
551 log::debug!("WebSocket connected");
552
553 let session_result = async {
554 book::recovery::reset_on_reconnect(
556 &self.book_sync,
557 &self.ws_client,
558 self.book_snapshot_timeout(),
559 &self.pending_tasks.spawner()?,
560 );
561 self.spawn_message_handler()?;
562 self.spawn_instrument_refresh()?;
563
564 if let Some(credential) = credential {
565 let ws_client = self.ws_client.clone();
566 self.session_tasks.spawn(run_auth_token_refresh(
567 self.http_client.clone(),
568 credential,
569 move |token| ws_client.update_auth_token(token),
570 ))?;
571 }
572 Ok::<(), anyhow::Error>(())
573 }
574 .await;
575
576 if let Err(e) = session_result {
577 if let Err(teardown_error) = self.teardown_partial_connect().await {
578 return Err(e.context(format!("AX data startup teardown failed: {teardown_error}")));
579 }
580 return Err(e);
581 }
582
583 self.is_connected.store(true, Ordering::Release);
584 setup_guard.disarm();
585 log::info!("Connected {}", self.client_id);
586
587 Ok(())
588 }
589
590 async fn disconnect(&mut self) -> anyhow::Result<()> {
591 log::info!("Disconnecting {}", self.client_id);
592
593 self.abort_all_tasks();
594 let ws_result = self.ws_client.close().await;
595 let tasks_result = self.finish_all_tasks().await;
596 self.funding_rate_cache.lock().clear();
597
598 self.is_connected.store(false, Ordering::Release);
599 log::info!("Disconnected {}", self.client_id);
600
601 ws_result?;
602 tasks_result
603 }
604
605 fn subscribe_instruments(&mut self, _cmd: SubscribeInstruments) -> anyhow::Result<()> {
606 log::debug!("Instruments subscription not applicable for AX (use request_instruments)");
608 Ok(())
609 }
610
611 fn subscribe_instrument(&mut self, _cmd: SubscribeInstrument) -> anyhow::Result<()> {
612 log::debug!("Instrument subscription not applicable for AX (use request_instrument)");
614 Ok(())
615 }
616
617 fn subscribe_book_deltas(&mut self, cmd: SubscribeBookDeltas) -> anyhow::Result<()> {
618 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
619 let cmd = cmd.with_instrument_ids(instrument_id);
620 let symbol = instrument_id.symbol;
621 let level = Self::map_book_type_to_market_data_level(cmd.book_type);
622 if cmd.book_type == BookType::L1_MBP {
623 log::warn!(
624 "Book type L1_MBP not supported by AX for deltas, downgrading {symbol} to LEVEL_2"
625 );
626 }
627
628 log::debug!("Subscribing to book deltas for {symbol} at {level:?}");
629
630 match self.pending_tasks.spawner() {
631 Ok(tasks) => book::recovery::spawn_subscription_task(
632 instrument_id,
633 level,
634 self.subscription_order.next(),
635 self.book_sync.clone(),
636 self.ws_client.clone(),
637 self.book_snapshot_timeout(),
638 &tasks,
639 ),
640 Err(e) => log::warn!("Skipping AX subscribe book deltas after shutdown began: {e}"),
641 }
642
643 Ok(())
644 })
645 }
646
647 fn subscribe_quotes(&mut self, cmd: SubscribeQuotes) -> anyhow::Result<()> {
648 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
649 self.ws_symbol_op(
650 instrument_id,
651 |ws, s| async move { ws.subscribe_quotes(&s).await },
652 "Subscribing to quotes",
653 )
654 })
655 }
656
657 fn subscribe_trades(&mut self, cmd: SubscribeTrades) -> anyhow::Result<()> {
658 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
659 self.ws_symbol_op(
660 instrument_id,
661 |ws, s| async move { ws.subscribe_trades(&s).await },
662 "Subscribing to trades",
663 )
664 })
665 }
666
667 fn subscribe_mark_prices(&mut self, cmd: SubscribeMarkPrices) -> anyhow::Result<()> {
668 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
669 self.ws_symbol_op(
670 instrument_id,
671 |ws, s| async move { ws.subscribe_mark_prices(&s).await },
672 "Subscribing to mark prices",
673 )
674 })
675 }
676
677 fn subscribe_index_prices(&mut self, _cmd: SubscribeIndexPrices) -> anyhow::Result<()> {
678 log::warn!("Index price subscriptions are not supported by the Architect AX adapter");
679 Ok(())
680 }
681
682 fn subscribe_bars(&mut self, cmd: SubscribeBars) -> anyhow::Result<()> {
683 execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
684 let symbol = bar_type.instrument_id().symbol.to_string();
685 let width = map_bar_spec_to_candle_width(&bar_type.spec())?;
686 log::debug!("Subscribing to bars for {bar_type} (width: {width:?})");
687
688 let ws = self.ws_client.clone();
689 self.spawn_ws(
690 async move {
691 ws.subscribe_candles(&symbol, width)
692 .await
693 .map_err(|e| anyhow::anyhow!(e))
694 },
695 "subscribe bars",
696 );
697
698 Ok(())
699 })
700 }
701
702 fn subscribe_funding_rates(&mut self, cmd: SubscribeFundingRates) -> anyhow::Result<()> {
703 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
704 let poll_interval_mins = self.config.funding_rate_poll_interval_mins.max(1);
705
706 let lookback = SignedDuration::from_hours(24 * (AX_FUNDING_RATE_LOOKBACK_DAYS));
708
709 if self.funding_rate_cancellations.contains_key(&instrument_id) {
710 log::debug!("Already subscribed to funding rates for {instrument_id}");
711 return Ok(());
712 }
713
714 log::debug!("Subscribing to funding rates for {instrument_id} (HTTP polling)");
715
716 let http = self.http_client.clone();
717 let sender = self.data_sender.clone();
718 let symbol = instrument_id.symbol.inner();
719 let cancellation = self.cancellation_token.child_token();
720 let task_cancellation = cancellation.clone();
721 let cache = Arc::clone(&self.funding_rate_cache);
722 let clock = self.clock;
723
724 self.session_tasks.spawn(async move {
725 let mut interval = tokio::time::interval(Duration::from_mins(poll_interval_mins));
727
728 loop {
729 tokio::select! {
730 () = task_cancellation.cancelled() => {
731 log::debug!("Funding rate polling cancelled for {symbol}");
732 break;
733 }
734 _ = interval.tick() => {
735 let now: Timestamp = clock.get_time_ns().into();
736 let start = now - lookback;
737
738 match http.request_funding_rates(instrument_id, Some(start), Some(now)).await {
739 Ok(funding_rates) => {
740 if funding_rates.is_empty() {
741 log::warn!(
742 "No funding rates returned for {symbol}"
743 );
744 } else if let Some(update) = funding_rates.last() {
745 let should_emit = cache.lock()
747 .get(&instrument_id) != Some(update);
748
749 if should_emit {
750 log::debug!(
751 "Funding rate for {symbol}: {}",
752 update.rate,
753 );
754 let update = *update;
755 cache.lock()
756 .insert(instrument_id, update);
757
758 if let Err(e) = sender.send(
759 DataEvent::Data(Data::FundingRate(update)),
760 ) {
761 log::error!(
762 "Failed to send funding rate for {symbol}: {e}"
763 );
764 }
765 }
766 }
767 }
768 Err(e) => {
769 log::error!(
770 "Failed to poll funding rates for {symbol}: {e}"
771 );
772 }
773 }
774 }
775 }
776 }
777 })?;
778
779 self.funding_rate_cancellations
780 .insert(instrument_id, cancellation);
781 Ok(())
782 })
783 }
784
785 fn subscribe_instrument_status(
786 &mut self,
787 cmd: SubscribeInstrumentStatus,
788 ) -> anyhow::Result<()> {
789 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
790 self.ws_symbol_op(
791 instrument_id,
792 |ws, s| async move { ws.subscribe_instrument_status(&s).await },
793 "Subscribing to instrument status",
794 )
795 })
796 }
797
798 fn subscribe_instrument_close(&mut self, _cmd: SubscribeInstrumentClose) -> anyhow::Result<()> {
799 log::warn!("Instrument close not supported by AX Exchange");
800 Ok(())
801 }
802
803 fn unsubscribe_instruments(&mut self, _cmd: &UnsubscribeInstruments) -> anyhow::Result<()> {
804 Ok(())
805 }
806
807 fn unsubscribe_instrument(&mut self, _cmd: &UnsubscribeInstrument) -> anyhow::Result<()> {
808 Ok(())
809 }
810
811 fn unsubscribe_book_deltas(&mut self, cmd: &UnsubscribeBookDeltas) -> anyhow::Result<()> {
812 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
813 self.book_sync.remove(instrument_id);
814
815 self.ws_symbol_op(
816 instrument_id,
817 |ws, s| async move { ws.unsubscribe_book_deltas(&s).await },
818 "Unsubscribing from book deltas",
819 )
820 })
821 }
822
823 fn unsubscribe_quotes(&mut self, cmd: &UnsubscribeQuotes) -> anyhow::Result<()> {
824 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
825 self.ws_symbol_op(
826 instrument_id,
827 |ws, s| async move { ws.unsubscribe_quotes(&s).await },
828 "Unsubscribing from quotes",
829 )
830 })
831 }
832
833 fn unsubscribe_trades(&mut self, cmd: &UnsubscribeTrades) -> anyhow::Result<()> {
834 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
835 self.ws_symbol_op(
836 instrument_id,
837 |ws, s| async move { ws.unsubscribe_trades(&s).await },
838 "Unsubscribing from trades",
839 )
840 })
841 }
842
843 fn unsubscribe_mark_prices(&mut self, cmd: &UnsubscribeMarkPrices) -> anyhow::Result<()> {
844 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
845 self.ws_symbol_op(
846 instrument_id,
847 |ws, s| async move { ws.unsubscribe_mark_prices(&s).await },
848 "Unsubscribing from mark prices",
849 )
850 })
851 }
852
853 fn unsubscribe_index_prices(&mut self, _cmd: &UnsubscribeIndexPrices) -> anyhow::Result<()> {
854 Ok(())
855 }
856
857 fn unsubscribe_bars(&mut self, cmd: &UnsubscribeBars) -> anyhow::Result<()> {
858 execute_bar_subscriptions(&cmd.bar_types, |bar_type| {
859 let symbol = bar_type.instrument_id().symbol.to_string();
860 let width = map_bar_spec_to_candle_width(&bar_type.spec())?;
861 log::debug!("Unsubscribing from bars for {bar_type}");
862
863 let ws = self.ws_client.clone();
864 self.spawn_ws(
865 async move {
866 ws.unsubscribe_candles(&symbol, width)
867 .await
868 .map_err(|e| anyhow::anyhow!(e))
869 },
870 "unsubscribe bars",
871 );
872
873 Ok(())
874 })
875 }
876
877 fn unsubscribe_funding_rates(&mut self, cmd: &UnsubscribeFundingRates) -> anyhow::Result<()> {
878 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
879 if let Some(cancellation) = self.funding_rate_cancellations.remove(&instrument_id) {
880 log::debug!("Unsubscribing from funding rates for {instrument_id}");
881 cancellation.cancel();
882 self.funding_rate_cache.lock().remove(&instrument_id);
883 } else {
884 log::debug!("Not subscribed to funding rates for {instrument_id}");
885 }
886
887 Ok(())
888 })
889 }
890
891 fn unsubscribe_instrument_status(
892 &mut self,
893 cmd: &UnsubscribeInstrumentStatus,
894 ) -> anyhow::Result<()> {
895 execute_instrument_subscriptions(&cmd.instrument_ids, |instrument_id| {
896 self.ws_symbol_op(
897 instrument_id,
898 |ws, s| async move { ws.unsubscribe_instrument_status(&s).await },
899 "Unsubscribing from instrument status",
900 )
901 })
902 }
903
904 fn unsubscribe_instrument_close(
905 &mut self,
906 _cmd: &UnsubscribeInstrumentClose,
907 ) -> anyhow::Result<()> {
908 Ok(())
909 }
910
911 fn request_instruments(&self, request: RequestInstruments) -> anyhow::Result<()> {
912 let http = self.http_client.clone();
913 let sender = self.data_sender.clone();
914 let cancel = self.cancellation_token.clone();
915 let request_id = request.request_id;
916 let client_id = request.client_id.unwrap_or(self.client_id);
917 let venue = *AX_VENUE;
918 let start_nanos = datetime_to_unix_nanos(request.start);
919 let end_nanos = datetime_to_unix_nanos(request.end);
920 let params = request.params;
921 let clock = self.clock;
922
923 self.spawn_task(async move {
924 match http.request_instruments().await {
925 Ok(instruments) => {
926 if cancel.is_cancelled() {
927 return;
928 }
929 log::debug!("Fetched {} instruments from Ax", instruments.len());
930 http.cache_instruments(&instruments);
931
932 let response = DataResponse::Instruments(InstrumentsResponse::new(
933 request_id,
934 client_id,
935 venue,
936 instruments,
937 start_nanos,
938 end_nanos,
939 clock.get_time_ns(),
940 params,
941 ));
942
943 if let Err(e) = sender.send(DataEvent::Response(response)) {
944 log::error!("Failed to send instruments response: {e}");
945 }
946 }
947 Err(e) => {
948 log::error!("Failed to request instruments: {e}");
949 }
950 }
951 });
952
953 Ok(())
954 }
955
956 fn request_instrument(&self, request: RequestInstrument) -> anyhow::Result<()> {
957 let instrument_id = request.single_instrument_id()?;
958
959 let http = self.http_client.clone();
960 let instruments_cache = Arc::clone(&self.instruments);
961 let sender = self.data_sender.clone();
962 let cancel = self.cancellation_token.clone();
963 let request_id = request.request_id;
964 let client_id = request.client_id.unwrap_or(self.client_id);
965 let symbol = instrument_id.symbol.inner();
966 let start_nanos = datetime_to_unix_nanos(request.start);
967 let end_nanos = datetime_to_unix_nanos(request.end);
968 let params = request.params;
969 let clock = self.clock;
970
971 self.spawn_task(async move {
972 match http.request_instrument(symbol).await {
973 Ok(instrument) => {
974 if cancel.is_cancelled() {
975 return;
976 }
977 log::debug!("Fetched instrument {symbol} from Ax");
978 instruments_cache.insert(symbol, instrument.clone());
979 http.cache_instrument(instrument.clone());
980
981 let response = DataResponse::Instrument(Box::new(InstrumentResponse::new(
982 request_id,
983 client_id,
984 instrument_id,
985 instrument,
986 start_nanos,
987 end_nanos,
988 clock.get_time_ns(),
989 params,
990 )));
991
992 if let Err(e) = sender.send(DataEvent::Response(response)) {
993 log::error!("Failed to send instrument response: {e}");
994 }
995 }
996 Err(e) => {
997 log::error!("Failed to request instrument {symbol}: {e}");
998 }
999 }
1000 });
1001
1002 Ok(())
1003 }
1004
1005 fn request_book_snapshot(&self, request: RequestBookSnapshot) -> anyhow::Result<()> {
1006 let instrument_id = request.single_instrument_id()?;
1007
1008 let http = self.http_client.clone();
1009 let sender = self.data_sender.clone();
1010 let cancel = self.cancellation_token.clone();
1011 let request_id = request.request_id;
1012 let client_id = request.client_id.unwrap_or(self.client_id);
1013 let symbol = instrument_id.symbol.inner();
1014 let depth = request.depth.map(|n| n.get());
1015 let params = request.params;
1016 let clock = self.clock;
1017
1018 self.spawn_task(async move {
1019 match http.request_book_snapshot(symbol, depth).await {
1020 Ok(book) => {
1021 if cancel.is_cancelled() {
1022 return;
1023 }
1024 log::debug!(
1025 "Fetched book snapshot for {symbol} ({} bids, {} asks)",
1026 book.bids(None).count(),
1027 book.asks(None).count(),
1028 );
1029
1030 let response = DataResponse::Book(BookResponse::new(
1031 request_id,
1032 client_id,
1033 instrument_id,
1034 book,
1035 None,
1036 None,
1037 clock.get_time_ns(),
1038 params,
1039 ));
1040
1041 if let Err(e) = sender.send(DataEvent::Response(response)) {
1042 log::error!("Failed to send book snapshot response: {e}");
1043 }
1044 }
1045 Err(e) => {
1046 log::error!("Failed to request book snapshot for {symbol}: {e}");
1047 }
1048 }
1049 });
1050
1051 Ok(())
1052 }
1053
1054 fn request_trades(&self, request: RequestTrades) -> anyhow::Result<()> {
1055 let instrument_id = request.single_instrument_id()?;
1056
1057 let http = self.http_client.clone();
1058 let sender = self.data_sender.clone();
1059 let cancel = self.cancellation_token.clone();
1060 let request_id = request.request_id;
1061 let client_id = request.client_id.unwrap_or(self.client_id);
1062 let symbol = instrument_id.symbol.inner();
1063 let limit = request.limit.map(|n| n.get() as i32);
1064 let start_nanos = datetime_to_unix_nanos(request.start);
1065 let end_nanos = datetime_to_unix_nanos(request.end);
1066 let params = request.params;
1067 let clock = self.clock;
1068
1069 self.spawn_task(async move {
1070 match http
1071 .request_trade_ticks(symbol, limit, start_nanos, end_nanos)
1072 .await
1073 {
1074 Ok(ticks) => {
1075 if cancel.is_cancelled() {
1076 return;
1077 }
1078 log::debug!("Fetched {} trades for {symbol}", ticks.len());
1079
1080 let response = DataResponse::Trades(TradesResponse::new(
1081 request_id,
1082 client_id,
1083 instrument_id,
1084 ticks,
1085 start_nanos,
1086 end_nanos,
1087 clock.get_time_ns(),
1088 params,
1089 ));
1090
1091 if let Err(e) = sender.send(DataEvent::Response(response)) {
1092 log::error!("Failed to send trades response: {e}");
1093 }
1094 }
1095 Err(e) => {
1096 log::error!("Failed to request trades for {symbol}: {e}");
1097 }
1098 }
1099 });
1100
1101 Ok(())
1102 }
1103
1104 fn request_bars(&self, request: RequestBars) -> anyhow::Result<()> {
1105 let bar_type = request.single_bar_type()?;
1106
1107 let http = self.http_client.clone();
1108 let sender = self.data_sender.clone();
1109 let request_id = request.request_id;
1110 let client_id = request.client_id.unwrap_or(self.client_id);
1111 let symbol = bar_type.instrument_id().symbol.inner();
1112 let start = request.start;
1113 let end = request.end;
1114 let start_nanos = datetime_to_unix_nanos(start);
1115 let end_nanos = datetime_to_unix_nanos(end);
1116 let params = request.params;
1117 let clock = self.clock;
1118 let width = map_bar_spec_to_candle_width(&bar_type.spec())?;
1119
1120 let cancel = self.cancellation_token.clone();
1121
1122 self.spawn_task(async move {
1123 match http.request_bars(symbol, start, end, width).await {
1124 Ok(bars) => {
1125 if cancel.is_cancelled() {
1126 return;
1127 }
1128 log::debug!("Fetched {} bars for {symbol}", bars.len());
1129
1130 let response = DataResponse::Bars(BarsResponse::new(
1131 request_id,
1132 client_id,
1133 bar_type,
1134 bars,
1135 start_nanos,
1136 end_nanos,
1137 clock.get_time_ns(),
1138 params,
1139 ));
1140
1141 if let Err(e) = sender.send(DataEvent::Response(response)) {
1142 log::error!("Failed to send bars response: {e}");
1143 }
1144 }
1145 Err(e) => {
1146 log::error!("Failed to request bars for {symbol}: {e}");
1147 }
1148 }
1149 });
1150
1151 Ok(())
1152 }
1153
1154 fn request_funding_rates(&self, request: RequestFundingRates) -> anyhow::Result<()> {
1155 let instrument_id = request.single_instrument_id()?;
1156
1157 let http = self.http_client.clone();
1158 let sender = self.data_sender.clone();
1159 let cancel = self.cancellation_token.clone();
1160 let request_id = request.request_id;
1161 let client_id = request.client_id.unwrap_or(self.client_id);
1162 let symbol = instrument_id.symbol.inner();
1163 let start = request.start;
1164 let end = request.end;
1165 let start_nanos = datetime_to_unix_nanos(start);
1166 let end_nanos = datetime_to_unix_nanos(end);
1167 let params = request.params;
1168 let clock = self.clock;
1169
1170 self.spawn_task(async move {
1171 match http.request_funding_rates(instrument_id, start, end).await {
1172 Ok(funding_rates) => {
1173 if cancel.is_cancelled() {
1174 return;
1175 }
1176 log::debug!("Fetched {} funding rates for {symbol}", funding_rates.len());
1177
1178 let ts_init = clock.get_time_ns();
1179 let response = DataResponse::FundingRates(FundingRatesResponse::new(
1180 request_id,
1181 client_id,
1182 instrument_id,
1183 funding_rates,
1184 start_nanos,
1185 end_nanos,
1186 ts_init,
1187 params,
1188 ));
1189
1190 if let Err(e) = sender.send(DataEvent::Response(response)) {
1191 log::error!("Failed to send funding rates response: {e}");
1192 }
1193 }
1194 Err(e) => {
1195 log::error!("Failed to request funding rates for {symbol}: {e}");
1196 }
1197 }
1198 });
1199
1200 Ok(())
1201 }
1202}
1203
1204fn drain_status_invalidations(
1205 invalidations: &Arc<Mutex<AHashSet<Ustr>>>,
1206 instrument_states: &mut AHashMap<Ustr, AxInstrumentState>,
1207) {
1208 for symbol in invalidations.lock().drain() {
1209 instrument_states.remove(&symbol);
1210 }
1211}
1212
1213#[expect(clippy::too_many_arguments)]
1214fn handle_ws_message(
1215 msg: AxDataWsMessage,
1216 sender: &EventSender<DataEvent>,
1217 instruments: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1218 symbol_data_types: &Arc<AtomicMap<String, SymbolDataTypes>>,
1219 book_sequences: &mut AHashMap<Ustr, u64>,
1220 candle_cache: &mut AHashMap<(Ustr, AxCandleWidth), AxMdCandle>,
1221 instrument_states: &mut AHashMap<Ustr, AxInstrumentState>,
1222 clock: &'static AtomicTime,
1223 book_sync: &BookSyncTracker,
1224 ws_client: &AxMdWebSocketClient,
1225 snapshot_timeout: Duration,
1226 book_tasks: &TaskSpawner,
1227) {
1228 match msg {
1229 AxDataWsMessage::Reconnected => {
1230 candle_cache.clear();
1231 instrument_states.clear();
1232 log::info!("WebSocket reconnected");
1233 book::recovery::reset_on_reconnect(book_sync, ws_client, snapshot_timeout, book_tasks);
1234 }
1235 AxDataWsMessage::CandleUnsubscribed { symbol, width } => {
1236 candle_cache.remove(&(symbol, width));
1237 }
1238 AxDataWsMessage::MdMessage(md_msg) => {
1239 handle_md_message(
1240 md_msg,
1241 sender,
1242 instruments,
1243 symbol_data_types,
1244 book_sequences,
1245 candle_cache,
1246 instrument_states,
1247 clock,
1248 book_sync,
1249 ws_client,
1250 snapshot_timeout,
1251 book_tasks,
1252 );
1253 }
1254 }
1255}
1256
1257#[expect(clippy::too_many_arguments)]
1258fn handle_md_message(
1259 message: AxMdMessage,
1260 sender: &EventSender<DataEvent>,
1261 instruments: &Arc<AtomicMap<Ustr, InstrumentAny>>,
1262 symbol_data_types: &Arc<AtomicMap<String, SymbolDataTypes>>,
1263 book_sequences: &mut AHashMap<Ustr, u64>,
1264 candle_cache: &mut AHashMap<(Ustr, AxCandleWidth), AxMdCandle>,
1265 instrument_states: &mut AHashMap<Ustr, AxInstrumentState>,
1266 clock: &'static AtomicTime,
1267 book_sync: &BookSyncTracker,
1268 ws_client: &AxMdWebSocketClient,
1269 snapshot_timeout: Duration,
1270 book_tasks: &TaskSpawner,
1271) {
1272 let ts_init = || -> UnixNanos { clock.get_time_ns() };
1273
1274 let instruments_snap = instruments.load();
1275 let sdt_snap = symbol_data_types.load();
1276
1277 match message {
1278 AxMdMessage::BookL1(book) => {
1279 let l1_subscribed = sdt_snap
1280 .get(book.s.as_str())
1281 .is_some_and(|e| e.quotes || e.book_level == Some(AxMarketDataLevel::Level1));
1282
1283 if !l1_subscribed {
1284 return;
1285 }
1286
1287 let Some(instrument) = instruments_snap.get(&book.s) else {
1288 log::error!(
1289 "No instrument cached for symbol '{}' - cannot parse L1 book",
1290 book.s
1291 );
1292 return;
1293 };
1294
1295 match parse_book_l1_quote(&book, instrument, ts_init()) {
1296 Ok(quote) => {
1297 let _ = sender.send(DataEvent::Data(Data::Quote(quote)));
1298 }
1299 Err(e) => log::error!("Failed to parse L1 to QuoteTick: {e}"),
1300 }
1301 }
1302 AxMdMessage::BookL2(book) => {
1303 let symbol = book.s;
1304 let seq = book_sequences.entry(symbol).or_insert(0);
1305 *seq += 1;
1306 let sequence = *seq;
1307
1308 let Some(instrument) = instruments_snap.get(&symbol) else {
1309 log::error!("No instrument cached for symbol '{symbol}' - cannot parse L2 book");
1310 return;
1311 };
1312
1313 handle_book_deltas(
1314 parse_book_l2_deltas(&book, instrument, sequence, ts_init()),
1315 instrument.id(),
1316 sender,
1317 book_sync,
1318 ws_client,
1319 snapshot_timeout,
1320 book_tasks,
1321 );
1322
1323 let quotes_subscribed = sdt_snap
1324 .get(symbol.as_str())
1325 .is_some_and(|entry| entry.quotes);
1326
1327 if quotes_subscribed {
1328 match parse_book_l2_quote(&book, instrument, ts_init()) {
1329 Ok(quote) => {
1330 let _ = sender.send(DataEvent::Data(Data::Quote(quote)));
1331 }
1332 Err(e) => log::error!("Failed to parse L2 to QuoteTick: {e}"),
1333 }
1334 }
1335 }
1336 AxMdMessage::BookL3(book) => {
1337 let symbol = book.s;
1338 let seq = book_sequences.entry(symbol).or_insert(0);
1339 *seq += 1;
1340 let sequence = *seq;
1341
1342 let Some(instrument) = instruments_snap.get(&symbol) else {
1343 log::error!("No instrument cached for symbol '{symbol}' - cannot parse L3 book");
1344 return;
1345 };
1346
1347 handle_book_deltas(
1348 parse_book_l3_deltas(&book, instrument, sequence, ts_init()),
1349 instrument.id(),
1350 sender,
1351 book_sync,
1352 ws_client,
1353 snapshot_timeout,
1354 book_tasks,
1355 );
1356
1357 let quotes_subscribed = sdt_snap
1358 .get(symbol.as_str())
1359 .is_some_and(|entry| entry.quotes);
1360
1361 if quotes_subscribed {
1362 match parse_book_l3_quote(&book, instrument, ts_init()) {
1363 Ok(quote) => {
1364 let _ = sender.send(DataEvent::Data(Data::Quote(quote)));
1365 }
1366 Err(e) => log::error!("Failed to parse L3 to QuoteTick: {e}"),
1367 }
1368 }
1369 }
1370 AxMdMessage::Ticker(ticker) => {
1371 let Some(instrument) = instruments_snap.get(&ticker.s) else {
1372 log::debug!("No instrument cached for ticker symbol '{}'", ticker.s);
1373 return;
1374 };
1375
1376 let instrument_id = instrument.id();
1377 let price_precision = instrument.price_precision();
1378 let ts_event =
1379 ax_timestamp_stn_to_unix_nanos(ticker.ts, ticker.tn).unwrap_or_else(|_| ts_init());
1380 let ts_init = ts_init();
1381
1382 let mark_prices_subscribed = sdt_snap
1383 .get(ticker.s.as_str())
1384 .is_some_and(|e| e.mark_prices);
1385
1386 if mark_prices_subscribed && let Some(mark_price) = ticker.m {
1387 match Price::from_decimal_dp(mark_price, price_precision) {
1388 Ok(price) => {
1389 let update = MarkPriceUpdate::new(instrument_id, price, ts_event, ts_init);
1390 let _ = sender.send(DataEvent::Data(Data::MarkPrice(update)));
1391 }
1392 Err(e) => {
1393 log::error!("Failed to parse mark price for {}: {e}", ticker.s);
1394 }
1395 }
1396 }
1397
1398 if let Some(state) = ticker.i {
1399 let status_subscribed = sdt_snap
1400 .get(ticker.s.as_str())
1401 .is_some_and(|e| e.instrument_status);
1402
1403 if status_subscribed {
1404 let prev = instrument_states.insert(ticker.s, state);
1405 if prev != Some(state) {
1406 let action = MarketStatusAction::from(state);
1407 let status = InstrumentStatus::new(
1408 instrument_id,
1409 action,
1410 ts_event,
1411 ts_init,
1412 None,
1413 None,
1414 Some(state == AxInstrumentState::Open),
1415 None,
1416 None,
1417 );
1418 let _ = sender.send(DataEvent::Data(Data::InstrumentStatus(status)));
1419 }
1420 }
1421 }
1422 }
1423 AxMdMessage::Trade(trade) => {
1424 let trades_subscribed = sdt_snap.get(trade.s.as_str()).is_some_and(|e| e.trades);
1425
1426 if !trades_subscribed {
1427 return;
1428 }
1429
1430 let Some(instrument) = instruments_snap.get(&trade.s) else {
1431 log::error!(
1432 "No instrument cached for symbol '{}' - cannot parse trade",
1433 trade.s
1434 );
1435 return;
1436 };
1437
1438 match parse_trade_tick(&trade, instrument, ts_init()) {
1439 Ok(tick) => {
1440 let _ = sender.send(DataEvent::Data(Data::Trade(tick)));
1441 }
1442 Err(e) => log::error!("Failed to parse trade to TradeTick: {e}"),
1443 }
1444 }
1445 AxMdMessage::Candle(candle) => {
1446 let cache_key = (candle.symbol, candle.width);
1447
1448 let closed_candle = if let Some(cached) = candle_cache.get(&cache_key) {
1449 if cached.ts == candle.ts {
1450 None
1451 } else {
1452 Some(cached.clone())
1453 }
1454 } else {
1455 None
1456 };
1457
1458 candle_cache.insert(cache_key, candle);
1459
1460 if let Some(closed) = closed_candle {
1461 let Some(instrument) = instruments_snap.get(&closed.symbol) else {
1462 log::error!(
1463 "No instrument cached for symbol '{}' - cannot parse candle",
1464 closed.symbol
1465 );
1466 return;
1467 };
1468
1469 match parse_candle_bar(&closed, instrument, ts_init()) {
1470 Ok(bar) => {
1471 let _ = sender.send(DataEvent::Data(Data::Bar(bar)));
1472 }
1473 Err(e) => log::error!("Failed to parse candle to Bar: {e}"),
1474 }
1475 }
1476 }
1477 AxMdMessage::Heartbeat(_) => {
1478 log::trace!("Received heartbeat");
1479 }
1480 AxMdMessage::SubscriptionResponse(_) | AxMdMessage::Error(_) => {}
1481 }
1482}
1483
1484fn handle_book_deltas(
1486 deltas: anyhow::Result<OrderBookDeltas>,
1487 instrument_id: InstrumentId,
1488 sender: &EventSender<DataEvent>,
1489 book_sync: &BookSyncTracker,
1490 ws_client: &AxMdWebSocketClient,
1491 snapshot_timeout: Duration,
1492 book_tasks: &TaskSpawner,
1493) {
1494 match deltas {
1495 Ok(deltas) => {
1496 if book_sync.record_snapshot(instrument_id, Instant::now()) {
1497 let _ = sender.send(DataEvent::Data(Data::BookDeltas(Box::new(deltas))));
1498 }
1499 }
1500 Err(e) => {
1501 log::error!("Failed to parse book frame for {instrument_id}: {e}");
1502 book::recovery::reject_snapshot(
1503 instrument_id,
1504 book_sync,
1505 ws_client,
1506 snapshot_timeout,
1507 book_tasks,
1508 );
1509 }
1510 }
1511}
1512
1513#[cfg(test)]
1514mod tests {
1515 use std::sync::Arc;
1516
1517 use ahash::{AHashMap, AHashSet};
1518 use nautilus_live::book::snapshot::SnapshotGate;
1519 use nautilus_model::{
1520 data::InstrumentStatus,
1521 enums::AssetClass,
1522 identifiers::{InstrumentId, Symbol},
1523 instruments::PerpetualContract,
1524 types::{Currency, Price, Quantity},
1525 };
1526 use nautilus_network::websocket::TransportBackend;
1527 use parking_lot::Mutex;
1528 use rstest::rstest;
1529 use rust_decimal::Decimal;
1530 use rust_decimal_macros::dec;
1531 use ustr::Ustr;
1532
1533 use super::*;
1534 use crate::websocket::{
1535 data::client::SymbolDataTypes,
1536 messages::{AxBookLevel, AxMdBookL2, AxMdMessage, AxMdTicker},
1537 };
1538
1539 fn book_context() -> (BookSyncTracker, AxMdWebSocketClient, TaskGroup) {
1540 let ws_client = AxMdWebSocketClient::new(
1541 "ws://localhost:9999/md/ws".to_string(),
1542 "test_token".to_string(),
1543 30,
1544 TransportBackend::default(),
1545 None,
1546 );
1547
1548 (BookSyncTracker::default(), ws_client, TaskGroup::new())
1549 }
1550
1551 #[rstest]
1552 fn test_drain_status_invalidations_removes_cached_state() {
1553 let invalidations = Arc::new(Mutex::new(AHashSet::new()));
1554 let mut states = AHashMap::new();
1555 let sym = Ustr::from("EURUSD-PERP");
1556
1557 states.insert(sym, AxInstrumentState::Open);
1558 invalidations.lock().insert(sym);
1559
1560 drain_status_invalidations(&invalidations, &mut states);
1561
1562 assert!(!states.contains_key(&sym));
1563 assert!(invalidations.lock().is_empty());
1564 }
1565
1566 #[rstest]
1567 fn test_drain_status_invalidations_no_op_when_empty() {
1568 let invalidations = Arc::new(Mutex::new(AHashSet::new()));
1569 let mut states = AHashMap::new();
1570 let sym = Ustr::from("EURUSD-PERP");
1571 states.insert(sym, AxInstrumentState::Open);
1572
1573 drain_status_invalidations(&invalidations, &mut states);
1574
1575 assert!(states.contains_key(&sym));
1576 }
1577
1578 fn ticker_test_instrument() -> InstrumentAny {
1579 let symbol = Symbol::new("EURUSD-PERP");
1580 let instrument = PerpetualContract::builder()
1581 .instrument_id(InstrumentId::new(symbol, *crate::common::consts::AX_VENUE))
1582 .raw_symbol(symbol)
1583 .underlying(Ustr::from("EURUSD"))
1584 .asset_class(AssetClass::FX)
1585 .quote_currency(Currency::USD())
1586 .settlement_currency(Currency::USD())
1587 .is_inverse(false)
1588 .price_precision(4)
1589 .size_precision(0)
1590 .price_increment(Price::from("0.0001"))
1591 .size_increment(Quantity::from("1"))
1592 .margin_init(Decimal::new(1, 2))
1593 .margin_maint(Decimal::new(5, 3))
1594 .ts_event(UnixNanos::default())
1595 .ts_init(UnixNanos::default())
1596 .build()
1597 .unwrap();
1598 InstrumentAny::PerpetualContract(instrument)
1599 }
1600
1601 fn ticker_message(state: AxInstrumentState) -> AxMdTicker {
1602 AxMdTicker {
1603 bp: None,
1604 ap: None,
1605 lst: None,
1606 ef: None,
1607 ts: 1_700_000_000,
1608 tn: 0,
1609 s: Ustr::from("EURUSD-PERP"),
1610 p: Some(rust_decimal::Decimal::ZERO),
1611 q: 0,
1612 o: Some(rust_decimal::Decimal::ZERO),
1613 l: Some(rust_decimal::Decimal::ZERO),
1614 h: Some(rust_decimal::Decimal::ZERO),
1615 v: 0,
1616 oi: None,
1617 m: None,
1618 i: Some(state),
1619 pl: None,
1620 pu: None,
1621 lsp: None,
1622 }
1623 }
1624
1625 fn collect_instrument_statuses(
1626 rx: &mut tokio::sync::mpsc::UnboundedReceiver<DataEvent>,
1627 ) -> Vec<InstrumentStatus> {
1628 let mut statuses = Vec::new();
1629
1630 while let Ok(event) = rx.try_recv() {
1631 if let DataEvent::Data(Data::InstrumentStatus(status)) = event {
1632 statuses.push(status);
1633 }
1634 }
1635 statuses
1636 }
1637
1638 #[rstest]
1639 fn test_ticker_instrument_status_emitted_once_when_state_unchanged() {
1640 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1641 let instruments = Arc::new(AtomicMap::new());
1642 instruments.insert(Ustr::from("EURUSD-PERP"), ticker_test_instrument());
1643
1644 let sdt = Arc::new(AtomicMap::new());
1645 sdt.insert(
1646 "EURUSD-PERP".to_string(),
1647 SymbolDataTypes {
1648 quotes: false,
1649 trades: false,
1650 mark_prices: false,
1651 instrument_status: true,
1652 book_level: None,
1653 },
1654 );
1655
1656 let mut book_sequences = AHashMap::new();
1657 let mut candle_cache = AHashMap::new();
1658 let mut instrument_states = AHashMap::new();
1659 let clock = get_atomic_clock_realtime();
1660 let (book_sync, ws_client, tasks) = book_context();
1661 let book_tasks = tasks.spawner().unwrap();
1662
1663 let msg = AxMdMessage::Ticker(Box::new(ticker_message(AxInstrumentState::Open)));
1664 handle_md_message(
1665 msg.clone(),
1666 &tx.clone().into(),
1667 &instruments,
1668 &sdt,
1669 &mut book_sequences,
1670 &mut candle_cache,
1671 &mut instrument_states,
1672 clock,
1673 &book_sync,
1674 &ws_client,
1675 Duration::from_secs(10),
1676 &book_tasks,
1677 );
1678
1679 handle_md_message(
1681 msg,
1682 &tx.into(),
1683 &instruments,
1684 &sdt,
1685 &mut book_sequences,
1686 &mut candle_cache,
1687 &mut instrument_states,
1688 clock,
1689 &book_sync,
1690 &ws_client,
1691 Duration::from_secs(10),
1692 &book_tasks,
1693 );
1694
1695 let statuses = collect_instrument_statuses(&mut rx);
1696 assert_eq!(
1697 statuses.len(),
1698 1,
1699 "expected a single emission, found {statuses:?}"
1700 );
1701 assert_eq!(statuses[0].is_trading, Some(true));
1702 }
1703
1704 #[rstest]
1705 fn test_ticker_instrument_status_emitted_on_transition() {
1706 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1707 let instruments = Arc::new(AtomicMap::new());
1708 instruments.insert(Ustr::from("EURUSD-PERP"), ticker_test_instrument());
1709
1710 let sdt = Arc::new(AtomicMap::new());
1711 sdt.insert(
1712 "EURUSD-PERP".to_string(),
1713 SymbolDataTypes {
1714 quotes: false,
1715 trades: false,
1716 mark_prices: false,
1717 instrument_status: true,
1718 book_level: None,
1719 },
1720 );
1721
1722 let mut book_sequences = AHashMap::new();
1723 let mut candle_cache = AHashMap::new();
1724 let mut instrument_states = AHashMap::new();
1725 let clock = get_atomic_clock_realtime();
1726 let (book_sync, ws_client, tasks) = book_context();
1727 let book_tasks = tasks.spawner().unwrap();
1728
1729 handle_md_message(
1730 AxMdMessage::Ticker(Box::new(ticker_message(AxInstrumentState::Open))),
1731 &tx.clone().into(),
1732 &instruments,
1733 &sdt,
1734 &mut book_sequences,
1735 &mut candle_cache,
1736 &mut instrument_states,
1737 clock,
1738 &book_sync,
1739 &ws_client,
1740 Duration::from_secs(10),
1741 &book_tasks,
1742 );
1743 handle_md_message(
1744 AxMdMessage::Ticker(Box::new(ticker_message(AxInstrumentState::Closed))),
1745 &tx.into(),
1746 &instruments,
1747 &sdt,
1748 &mut book_sequences,
1749 &mut candle_cache,
1750 &mut instrument_states,
1751 clock,
1752 &book_sync,
1753 &ws_client,
1754 Duration::from_secs(10),
1755 &book_tasks,
1756 );
1757
1758 let statuses = collect_instrument_statuses(&mut rx);
1759 assert_eq!(statuses.len(), 2, "expected one emission per transition");
1760 assert_eq!(statuses[0].is_trading, Some(true));
1761 assert_eq!(statuses[1].is_trading, Some(false));
1762 }
1763
1764 #[rstest]
1765 fn test_ticker_instrument_status_skipped_when_not_subscribed() {
1766 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1767 let instruments = Arc::new(AtomicMap::new());
1768 instruments.insert(Ustr::from("EURUSD-PERP"), ticker_test_instrument());
1769
1770 let sdt = Arc::new(AtomicMap::new());
1771 sdt.insert(
1772 "EURUSD-PERP".to_string(),
1773 SymbolDataTypes {
1774 quotes: false,
1775 trades: false,
1776 mark_prices: false,
1777 instrument_status: false,
1778 book_level: None,
1779 },
1780 );
1781
1782 let mut book_sequences = AHashMap::new();
1783 let mut candle_cache = AHashMap::new();
1784 let mut instrument_states = AHashMap::new();
1785 let clock = get_atomic_clock_realtime();
1786 let (book_sync, ws_client, tasks) = book_context();
1787 let book_tasks = tasks.spawner().unwrap();
1788
1789 handle_md_message(
1790 AxMdMessage::Ticker(Box::new(ticker_message(AxInstrumentState::Open))),
1791 &tx.into(),
1792 &instruments,
1793 &sdt,
1794 &mut book_sequences,
1795 &mut candle_cache,
1796 &mut instrument_states,
1797 clock,
1798 &book_sync,
1799 &ws_client,
1800 Duration::from_secs(10),
1801 &book_tasks,
1802 );
1803
1804 let statuses = collect_instrument_statuses(&mut rx);
1805 assert!(statuses.is_empty());
1806 }
1807
1808 #[rstest]
1809 fn test_l2_book_emits_quote_when_quotes_subscribed() {
1810 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1811 let instruments = Arc::new(AtomicMap::new());
1812 instruments.insert(Ustr::from("EURUSD-PERP"), ticker_test_instrument());
1813
1814 let sdt = Arc::new(AtomicMap::new());
1815 sdt.insert(
1816 "EURUSD-PERP".to_string(),
1817 SymbolDataTypes {
1818 quotes: true,
1819 book_level: Some(AxMarketDataLevel::Level2),
1820 ..Default::default()
1821 },
1822 );
1823
1824 let mut book_sequences = AHashMap::new();
1825 let mut candle_cache = AHashMap::new();
1826 let mut instrument_states = AHashMap::new();
1827 let clock = get_atomic_clock_realtime();
1828 let (book_sync, ws_client, tasks) = book_context();
1829 let book_tasks = tasks.spawner().unwrap();
1830
1831 handle_md_message(
1832 l2_book_message(),
1833 &tx.into(),
1834 &instruments,
1835 &sdt,
1836 &mut book_sequences,
1837 &mut candle_cache,
1838 &mut instrument_states,
1839 clock,
1840 &book_sync,
1841 &ws_client,
1842 Duration::from_secs(10),
1843 &book_tasks,
1844 );
1845
1846 let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
1847 let quote = events.iter().find_map(|event| match event {
1848 DataEvent::Data(Data::Quote(quote)) => Some(quote),
1849 _ => None,
1850 });
1851
1852 assert_eq!(
1853 quote.map(|quote| quote.bid_price),
1854 Some(Price::from("1.1441"))
1855 );
1856 assert_eq!(
1857 quote.map(|quote| quote.ask_price),
1858 Some(Price::from("1.1448"))
1859 );
1860 }
1861
1862 fn l2_book_message() -> AxMdMessage {
1863 AxMdMessage::BookL2(AxMdBookL2 {
1864 ts: 1_700_000_000,
1865 tn: 123,
1866 s: Ustr::from("EURUSD-PERP"),
1867 b: vec![AxBookLevel {
1868 p: dec!(1.1441),
1869 q: 100,
1870 }],
1871 a: vec![AxBookLevel {
1872 p: dec!(1.1448),
1873 q: 200,
1874 }],
1875 st: true,
1876 })
1877 }
1878
1879 #[rstest]
1880 #[case::untracked(false, 0)]
1881 #[case::tracked(true, 1)]
1882 fn test_l2_book_emits_deltas_only_for_tracked_book(
1883 #[case] tracked: bool,
1884 #[case] expected: usize,
1885 ) {
1886 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1887 let instruments = Arc::new(AtomicMap::new());
1888 let instrument = ticker_test_instrument();
1889 instruments.insert(Ustr::from("EURUSD-PERP"), instrument.clone());
1890 let sdt = Arc::new(AtomicMap::new());
1891 let mut book_sequences = AHashMap::new();
1892 let mut candle_cache = AHashMap::new();
1893 let mut instrument_states = AHashMap::new();
1894 let clock = get_atomic_clock_realtime();
1895 let (book_sync, ws_client, tasks) = book_context();
1896 let book_tasks = tasks.spawner().unwrap();
1897
1898 if tracked {
1899 book_sync.record_subscription(instrument.id(), Instant::now(), SnapshotGate::default());
1900 }
1901
1902 handle_md_message(
1903 l2_book_message(),
1904 &tx.into(),
1905 &instruments,
1906 &sdt,
1907 &mut book_sequences,
1908 &mut candle_cache,
1909 &mut instrument_states,
1910 clock,
1911 &book_sync,
1912 &ws_client,
1913 Duration::from_secs(10),
1914 &book_tasks,
1915 );
1916
1917 let deltas = std::iter::from_fn(|| rx.try_recv().ok())
1918 .filter_map(|event| match event {
1919 DataEvent::Data(Data::BookDeltas(deltas)) => Some(deltas),
1920 _ => None,
1921 })
1922 .collect::<Vec<_>>();
1923
1924 assert_eq!(deltas.len(), expected);
1925 assert!(
1926 deltas
1927 .iter()
1928 .all(|deltas| deltas.instrument_id == instrument.id())
1929 );
1930 }
1931}