Skip to main content

nautilus_testkit/testers/data/
actor.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
16use std::{num::NonZeroUsize, time::Duration};
17
18use ahash::{AHashMap, AHashSet};
19use chrono::Duration as ChronoDuration;
20use nautilus_common::{
21    actor::{DataActor, DataActorCore},
22    config::ConfigError,
23    enums::LogColor,
24    log_info, nautilus_actor,
25    timer::TimeEvent,
26};
27use nautilus_model::{
28    data::{
29        Bar, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
30        MarkPriceUpdate, OrderBookDeltas, OrderBookDepth10, QuoteTick, TradeTick,
31        option_chain::OptionGreeks,
32    },
33    identifiers::InstrumentId,
34    instruments::InstrumentAny,
35    orderbook::OrderBook,
36};
37
38use super::config::DataTesterConfig;
39
40/// A data tester actor for live testing market data subscriptions.
41///
42/// Subscribes to configured data types for specified instruments and logs
43/// received data to demonstrate the data flow. Useful for testing adapters
44/// and validating data connectivity.
45///
46/// This actor provides equivalent functionality to the Python `DataTester`
47/// in the test kit.
48#[derive(Debug)]
49pub struct DataTester {
50    pub(super) core: DataActorCore,
51    pub(super) config: DataTesterConfig,
52    pub(super) books: AHashMap<InstrumentId, OrderBook>,
53}
54
55nautilus_actor!(DataTester);
56
57impl DataActor for DataTester {
58    #[expect(
59        clippy::too_many_lines,
60        reason = "startup subscribes to each configured data scenario explicitly"
61    )]
62    fn on_start(&mut self) -> anyhow::Result<()> {
63        let instrument_ids = self.config.instrument_ids.clone();
64        let client_id = self.config.client_id;
65        let subscribe_params = self.config.subscribe_params.clone();
66        let request_params = self.config.request_params.clone();
67        let stats_interval_secs = self.config.stats_interval_secs;
68
69        // Request instruments if configured
70        if self.config.request_instruments {
71            let mut venues = AHashSet::new();
72            for instrument_id in &instrument_ids {
73                venues.insert(instrument_id.venue);
74            }
75
76            for venue in venues {
77                let _ = self.request_instruments(
78                    Some(venue),
79                    None,
80                    None,
81                    client_id,
82                    request_params.clone(),
83                );
84            }
85        }
86
87        // Subscribe to data for each instrument
88        for instrument_id in instrument_ids {
89            if self.config.subscribe_instrument {
90                self.subscribe_instrument(instrument_id, client_id, subscribe_params.clone());
91            }
92
93            if self.config.subscribe_book_deltas {
94                self.subscribe_book_deltas(
95                    instrument_id,
96                    self.config.book_type,
97                    None,
98                    client_id,
99                    self.config.manage_book,
100                    subscribe_params.clone(),
101                );
102
103                if self.config.manage_book {
104                    let book = OrderBook::new(instrument_id, self.config.book_type);
105                    self.books.insert(instrument_id, book);
106                }
107            }
108
109            if self.config.subscribe_book_at_interval {
110                self.subscribe_book_at_interval(
111                    instrument_id,
112                    self.config.book_type,
113                    self.config
114                        .book_depth
115                        .map(|depth| {
116                            NonZeroUsize::new(depth).ok_or_else(|| {
117                                ConfigError::range("book_depth", "must be positive, was 0")
118                            })
119                        })
120                        .transpose()?,
121                    NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
122                        ConfigError::range("book_interval_ms", "must be positive, was 0")
123                    })?,
124                    client_id,
125                    subscribe_params.clone(),
126                );
127            }
128
129            if self.config.subscribe_book_depth {
130                self.subscribe_book_depth10(
131                    instrument_id,
132                    self.config.book_type,
133                    client_id,
134                    self.config.manage_book,
135                    subscribe_params.clone(),
136                );
137            }
138
139            if self.config.subscribe_quotes {
140                self.subscribe_quotes(instrument_id, client_id, subscribe_params.clone());
141            }
142
143            if self.config.subscribe_trades {
144                self.subscribe_trades(instrument_id, client_id, subscribe_params.clone());
145            }
146
147            if self.config.subscribe_mark_prices {
148                self.subscribe_mark_prices(instrument_id, client_id, subscribe_params.clone());
149            }
150
151            if self.config.subscribe_index_prices {
152                self.subscribe_index_prices(instrument_id, client_id, subscribe_params.clone());
153            }
154
155            if self.config.subscribe_funding_rates {
156                self.subscribe_funding_rates(instrument_id, client_id, subscribe_params.clone());
157            }
158
159            if self.config.subscribe_instrument_status {
160                self.subscribe_instrument_status(
161                    instrument_id,
162                    client_id,
163                    subscribe_params.clone(),
164                );
165            }
166
167            if self.config.subscribe_instrument_close {
168                self.subscribe_instrument_close(instrument_id, client_id, subscribe_params.clone());
169            }
170
171            if self.config.subscribe_option_greeks {
172                self.subscribe_option_greeks(instrument_id, client_id, subscribe_params.clone());
173            }
174
175            // Request historical quotes (default to last 1 hour)
176            if self.config.request_quotes {
177                let start = self.clock().utc_now() - ChronoDuration::hours(1);
178
179                if let Err(e) = self.request_quotes(
180                    instrument_id,
181                    Some(start),
182                    None,
183                    None,
184                    client_id,
185                    request_params.clone(),
186                ) {
187                    log::error!("Failed to request quotes for {instrument_id}: {e}");
188                }
189            }
190
191            // Request order book snapshot if configured
192            if self.config.request_book_snapshot {
193                let _ = self.request_book_snapshot(
194                    instrument_id,
195                    self.config
196                        .book_depth
197                        .map(|depth| {
198                            NonZeroUsize::new(depth).ok_or_else(|| {
199                                ConfigError::range("book_depth", "must be positive, was 0")
200                            })
201                        })
202                        .transpose()?,
203                    client_id,
204                    request_params.clone(),
205                );
206            }
207
208            // TODO: Request book deltas when Rust data engine has RequestBookDeltas
209
210            // Request historical trades (default to last 1 hour)
211            if self.config.request_trades {
212                let start = self.clock().utc_now() - ChronoDuration::hours(1);
213
214                if let Err(e) = self.request_trades(
215                    instrument_id,
216                    Some(start),
217                    None,
218                    None,
219                    client_id,
220                    request_params.clone(),
221                ) {
222                    log::error!("Failed to request trades for {instrument_id}: {e}");
223                }
224            }
225
226            // Request historical funding rates (default to last 7 days)
227            if self.config.request_funding_rates {
228                let start = self.clock().utc_now() - ChronoDuration::days(7);
229
230                if let Err(e) = self.request_funding_rates(
231                    instrument_id,
232                    Some(start),
233                    None,
234                    None,
235                    client_id,
236                    request_params.clone(),
237                ) {
238                    log::error!("Failed to request funding rates for {instrument_id}: {e}");
239                }
240            }
241        }
242
243        // Subscribe to bars
244        if let Some(bar_types) = self.config.bar_types.clone() {
245            for bar_type in bar_types {
246                if self.config.subscribe_bars {
247                    self.subscribe_bars(bar_type, client_id, subscribe_params.clone());
248                }
249
250                // Request historical bars (default to last 1 hour)
251                if self.config.request_bars {
252                    let start = self.clock().utc_now() - ChronoDuration::hours(1);
253
254                    if let Err(e) = self.request_bars(
255                        bar_type,
256                        Some(start),
257                        None,
258                        None,
259                        client_id,
260                        request_params.clone(),
261                    ) {
262                        log::error!("Failed to request bars for {bar_type}: {e}");
263                    }
264                }
265            }
266        }
267
268        // Set up stats timer
269        if stats_interval_secs > 0 {
270            self.clock().set_timer(
271                "STATS-TIMER",
272                Duration::from_secs(stats_interval_secs),
273                None,
274                None,
275                None,
276                Some(true),
277                Some(false),
278            )?;
279        }
280
281        Ok(())
282    }
283
284    fn on_stop(&mut self) -> anyhow::Result<()> {
285        if !self.config.can_unsubscribe {
286            return Ok(());
287        }
288
289        let instrument_ids = self.config.instrument_ids.clone();
290        let client_id = self.config.client_id;
291        let subscribe_params = self.config.subscribe_params.clone();
292
293        for instrument_id in instrument_ids {
294            if self.config.subscribe_instrument {
295                self.unsubscribe_instrument(instrument_id, client_id, subscribe_params.clone());
296            }
297
298            if self.config.subscribe_book_deltas {
299                self.unsubscribe_book_deltas(instrument_id, client_id, subscribe_params.clone());
300            }
301
302            if self.config.subscribe_book_at_interval {
303                self.unsubscribe_book_at_interval(
304                    instrument_id,
305                    NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
306                        ConfigError::range("book_interval_ms", "must be positive, was 0")
307                    })?,
308                    client_id,
309                    subscribe_params.clone(),
310                );
311            }
312
313            if self.config.subscribe_book_depth {
314                self.unsubscribe_book_depth10(instrument_id, client_id, subscribe_params.clone());
315            }
316
317            if self.config.subscribe_quotes {
318                self.unsubscribe_quotes(instrument_id, client_id, subscribe_params.clone());
319            }
320
321            if self.config.subscribe_trades {
322                self.unsubscribe_trades(instrument_id, client_id, subscribe_params.clone());
323            }
324
325            if self.config.subscribe_mark_prices {
326                self.unsubscribe_mark_prices(instrument_id, client_id, subscribe_params.clone());
327            }
328
329            if self.config.subscribe_index_prices {
330                self.unsubscribe_index_prices(instrument_id, client_id, subscribe_params.clone());
331            }
332
333            if self.config.subscribe_funding_rates {
334                self.unsubscribe_funding_rates(instrument_id, client_id, subscribe_params.clone());
335            }
336
337            if self.config.subscribe_instrument_status {
338                self.unsubscribe_instrument_status(
339                    instrument_id,
340                    client_id,
341                    subscribe_params.clone(),
342                );
343            }
344
345            if self.config.subscribe_instrument_close {
346                self.unsubscribe_instrument_close(
347                    instrument_id,
348                    client_id,
349                    subscribe_params.clone(),
350                );
351            }
352
353            if self.config.subscribe_option_greeks {
354                self.unsubscribe_option_greeks(instrument_id, client_id, subscribe_params.clone());
355            }
356        }
357
358        if let Some(bar_types) = self.config.bar_types.clone() {
359            for bar_type in bar_types {
360                if self.config.subscribe_bars {
361                    self.unsubscribe_bars(bar_type, client_id, subscribe_params.clone());
362                }
363            }
364        }
365
366        Ok(())
367    }
368
369    fn on_time_event(&mut self, _event: &TimeEvent) -> anyhow::Result<()> {
370        // Timer events are used by the actor but don't require specific handling
371        Ok(())
372    }
373
374    fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
375        if self.config.log_data {
376            log_info!("{instrument:?}", color = LogColor::Cyan);
377        }
378        Ok(())
379    }
380
381    fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
382        if self.config.log_data {
383            let levels = self.config.book_levels_to_print;
384            let instrument_id = book.instrument_id;
385            let book_str = book.pprint(levels, None);
386            log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
387        }
388
389        Ok(())
390    }
391
392    fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
393        if self.config.manage_book {
394            if let Some(book) = self.books.get_mut(&deltas.instrument_id) {
395                book.apply_deltas(deltas)?;
396
397                if self.config.log_data {
398                    let levels = self.config.book_levels_to_print;
399                    let instrument_id = deltas.instrument_id;
400                    let book_str = book.pprint(levels, None);
401                    log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
402                }
403            }
404        } else if self.config.log_data {
405            log_info!("{deltas:?}", color = LogColor::Cyan);
406        }
407        Ok(())
408    }
409
410    fn on_book_depth(&mut self, depth: &OrderBookDepth10) -> anyhow::Result<()> {
411        if self.config.log_data {
412            log_info!("{depth:?}", color = LogColor::Cyan);
413        }
414        Ok(())
415    }
416
417    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
418        if self.config.log_data {
419            log_info!("{quote:?}", color = LogColor::Cyan);
420        }
421        Ok(())
422    }
423
424    fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
425        if self.config.log_data {
426            log_info!("{trade:?}", color = LogColor::Cyan);
427        }
428        Ok(())
429    }
430
431    fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
432        if self.config.log_data {
433            log_info!("{bar:?}", color = LogColor::Cyan);
434        }
435        Ok(())
436    }
437
438    fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
439        if self.config.log_data {
440            log_info!("{mark_price:?}", color = LogColor::Cyan);
441        }
442        Ok(())
443    }
444
445    fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
446        if self.config.log_data {
447            log_info!("{index_price:?}", color = LogColor::Cyan);
448        }
449        Ok(())
450    }
451
452    fn on_funding_rate(&mut self, funding_rate: &FundingRateUpdate) -> anyhow::Result<()> {
453        if self.config.log_data {
454            log_info!("{funding_rate:?}", color = LogColor::Cyan);
455        }
456        Ok(())
457    }
458
459    fn on_instrument_status(&mut self, data: &InstrumentStatus) -> anyhow::Result<()> {
460        if self.config.log_data {
461            log_info!("{data:?}", color = LogColor::Cyan);
462        }
463        Ok(())
464    }
465
466    fn on_instrument_close(&mut self, update: &InstrumentClose) -> anyhow::Result<()> {
467        if self.config.log_data {
468            log_info!("{update:?}", color = LogColor::Cyan);
469        }
470        Ok(())
471    }
472
473    fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
474        if self.config.log_data {
475            log_info!("{greeks:?}", color = LogColor::Cyan);
476        }
477        Ok(())
478    }
479
480    fn on_historical_trades(&mut self, trades: &[TradeTick]) -> anyhow::Result<()> {
481        if self.config.log_data {
482            log_info!(
483                "Received {} historical trades",
484                trades.len(),
485                color = LogColor::Cyan
486            );
487
488            for trade in trades.iter().take(5) {
489                log_info!("  {trade:?}", color = LogColor::Cyan);
490            }
491
492            if trades.len() > 5 {
493                log_info!(
494                    "  ... and {} more trades",
495                    trades.len() - 5,
496                    color = LogColor::Cyan
497                );
498            }
499        }
500        Ok(())
501    }
502
503    fn on_historical_quotes(&mut self, quotes: &[QuoteTick]) -> anyhow::Result<()> {
504        if self.config.log_data {
505            log_info!(
506                "Received {} historical quotes",
507                quotes.len(),
508                color = LogColor::Cyan
509            );
510
511            for quote in quotes.iter().take(5) {
512                log_info!("  {quote:?}", color = LogColor::Cyan);
513            }
514
515            if quotes.len() > 5 {
516                log_info!(
517                    "  ... and {} more quotes",
518                    quotes.len() - 5,
519                    color = LogColor::Cyan
520                );
521            }
522        }
523        Ok(())
524    }
525
526    fn on_historical_funding_rates(
527        &mut self,
528        funding_rates: &[FundingRateUpdate],
529    ) -> anyhow::Result<()> {
530        if self.config.log_data {
531            log_info!(
532                "Received {} historical funding rates",
533                funding_rates.len(),
534                color = LogColor::Cyan
535            );
536
537            for rate in funding_rates.iter().take(5) {
538                log_info!("  {rate:?}", color = LogColor::Cyan);
539            }
540
541            if funding_rates.len() > 5 {
542                log_info!(
543                    "  ... and {} more funding rates",
544                    funding_rates.len() - 5,
545                    color = LogColor::Cyan
546                );
547            }
548        }
549        Ok(())
550    }
551
552    fn on_historical_bars(&mut self, bars: &[Bar]) -> anyhow::Result<()> {
553        if self.config.log_data {
554            log_info!(
555                "Received {} historical bars",
556                bars.len(),
557                color = LogColor::Cyan
558            );
559
560            for bar in bars.iter().take(5) {
561                log_info!("  {bar:?}", color = LogColor::Cyan);
562            }
563
564            if bars.len() > 5 {
565                log_info!(
566                    "  ... and {} more bars",
567                    bars.len() - 5,
568                    color = LogColor::Cyan
569                );
570            }
571        }
572        Ok(())
573    }
574}
575
576impl DataTester {
577    /// Creates a new [`DataTester`] instance.
578    #[must_use]
579    pub fn new(config: DataTesterConfig) -> Self {
580        Self {
581            core: DataActorCore::new(config.base.clone()),
582            config,
583            books: AHashMap::new(),
584        }
585    }
586}