Skip to main content

nautilus_architect_ax/
data.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Live market data client implementation for the AX Exchange adapter.
17
18use 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/// AX Exchange data client for live market data streaming and historical data requests.
96///
97/// This client integrates with the Nautilus DataEngine to provide:
98/// - Real-time market data via WebSocket subscriptions
99/// - Historical data via REST API requests
100/// - Automatic instrument discovery and caching
101/// - Connection lifecycle management
102#[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    /// Creates a new [`AxDataClient`] instance.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if the data event sender cannot be obtained.
128    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        // Share instruments cache with HTTP client
143        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    /// Returns the venue for this data client.
169    #[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    /// Returns a reference to the instruments cache.
182    #[must_use]
183    pub fn instruments(&self) -> &Arc<AtomicMap<Ustr, InstrumentAny>> {
184        &self.instruments
185    }
186
187    /// Spawns a message handler task to forward WebSocket data to the DataEngine.
188    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    // Runs each subscription change after the earlier ones, so the venue sees command order
330    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            // Only an authenticated client can read fee rates, and a data client may
514            // legitimately run without credentials.
515            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            // The client replays subscriptions on connect, so tracked books need fresh snapshots
555            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        // AX does not have a real-time instruments channel; instruments are fetched via HTTP
607        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        // AX does not have a real-time instrument channel; instruments are fetched via HTTP
613        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            // Use 7-day lookback to capture latest rate across weekends/holidays
707            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                // First tick fires immediately for initial emission
726                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                                        // Only emit if rate changed
746                                        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
1484// Emits a book snapshot its book accepts, or recovers a book whose frame failed to parse
1485fn 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        // Same state repeated: second call should not emit a second InstrumentStatus
1680        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}