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, QuoteTick, TradeTick, option_chain::OptionGreeks,
31    },
32    identifiers::InstrumentId,
33    instruments::InstrumentAny,
34    orderbook::OrderBook,
35};
36
37use super::config::DataTesterConfig;
38
39/// A data tester actor for live testing market data subscriptions.
40///
41/// Subscribes to configured data types for specified instruments and logs
42/// received data to demonstrate the data flow. Useful for testing adapters
43/// and validating data connectivity.
44///
45/// This actor provides equivalent functionality to the Python `DataTester`
46/// in the test kit.
47#[derive(Debug)]
48pub struct DataTester {
49    pub(super) core: DataActorCore,
50    pub(super) config: DataTesterConfig,
51    pub(super) books: AHashMap<InstrumentId, OrderBook>,
52}
53
54nautilus_actor!(DataTester);
55
56impl DataActor for DataTester {
57    #[expect(
58        clippy::too_many_lines,
59        reason = "startup subscribes to each configured data scenario explicitly"
60    )]
61    fn on_start(&mut self) -> anyhow::Result<()> {
62        let instrument_ids = self.config.instrument_ids.clone();
63        let client_id = self.config.client_id;
64        let subscribe_params = self.config.subscribe_params.clone();
65        let request_params = self.config.request_params.clone();
66        let stats_interval_secs = self.config.stats_interval_secs;
67
68        // Request instruments if configured
69        if self.config.request_instruments {
70            let mut venues = AHashSet::new();
71            for instrument_id in &instrument_ids {
72                venues.insert(instrument_id.venue);
73            }
74
75            for venue in venues {
76                let _ = self.request_instruments(
77                    Some(venue),
78                    None,
79                    None,
80                    client_id,
81                    request_params.clone(),
82                );
83            }
84        }
85
86        // Subscribe to data for each instrument
87        for instrument_id in instrument_ids {
88            if self.config.subscribe_instrument {
89                self.subscribe_instrument(instrument_id, client_id, subscribe_params.clone());
90            }
91
92            if self.config.subscribe_book_deltas {
93                self.subscribe_book_deltas(
94                    instrument_id,
95                    self.config.book_type,
96                    None,
97                    client_id,
98                    self.config.manage_book,
99                    subscribe_params.clone(),
100                );
101
102                if self.config.manage_book {
103                    let book = OrderBook::new(instrument_id, self.config.book_type);
104                    self.books.insert(instrument_id, book);
105                }
106            }
107
108            if self.config.subscribe_book_at_interval {
109                self.subscribe_book_at_interval(
110                    instrument_id,
111                    self.config.book_type,
112                    self.config
113                        .book_depth
114                        .map(|depth| {
115                            NonZeroUsize::new(depth).ok_or_else(|| {
116                                ConfigError::range("book_depth", "must be positive, was 0")
117                            })
118                        })
119                        .transpose()?,
120                    NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
121                        ConfigError::range("book_interval_ms", "must be positive, was 0")
122                    })?,
123                    client_id,
124                    subscribe_params.clone(),
125                );
126            }
127
128            // TODO: Support subscribe_book_depth when the method is available
129            // if self.config.subscribe_book_depth {
130            //     self.subscribe_book_depth(
131            //         instrument_id,
132            //         self.config.book_type,
133            //         self.config.book_depth,
134            //         client_id,
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            // TODO: Support unsubscribe_book_depth when the method is available
314            // if self.config.subscribe_book_depth {
315            //     self.unsubscribe_book_depth(instrument_id, client_id, subscribe_params.clone());
316            // }
317
318            if self.config.subscribe_quotes {
319                self.unsubscribe_quotes(instrument_id, client_id, subscribe_params.clone());
320            }
321
322            if self.config.subscribe_trades {
323                self.unsubscribe_trades(instrument_id, client_id, subscribe_params.clone());
324            }
325
326            if self.config.subscribe_mark_prices {
327                self.unsubscribe_mark_prices(instrument_id, client_id, subscribe_params.clone());
328            }
329
330            if self.config.subscribe_index_prices {
331                self.unsubscribe_index_prices(instrument_id, client_id, subscribe_params.clone());
332            }
333
334            if self.config.subscribe_funding_rates {
335                self.unsubscribe_funding_rates(instrument_id, client_id, subscribe_params.clone());
336            }
337
338            if self.config.subscribe_instrument_status {
339                self.unsubscribe_instrument_status(
340                    instrument_id,
341                    client_id,
342                    subscribe_params.clone(),
343                );
344            }
345
346            if self.config.subscribe_instrument_close {
347                self.unsubscribe_instrument_close(
348                    instrument_id,
349                    client_id,
350                    subscribe_params.clone(),
351                );
352            }
353
354            if self.config.subscribe_option_greeks {
355                self.unsubscribe_option_greeks(instrument_id, client_id, subscribe_params.clone());
356            }
357        }
358
359        if let Some(bar_types) = self.config.bar_types.clone() {
360            for bar_type in bar_types {
361                if self.config.subscribe_bars {
362                    self.unsubscribe_bars(bar_type, client_id, subscribe_params.clone());
363                }
364            }
365        }
366
367        Ok(())
368    }
369
370    fn on_time_event(&mut self, _event: &TimeEvent) -> anyhow::Result<()> {
371        // Timer events are used by the actor but don't require specific handling
372        Ok(())
373    }
374
375    fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
376        if self.config.log_data {
377            log_info!("{instrument:?}", color = LogColor::Cyan);
378        }
379        Ok(())
380    }
381
382    fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
383        if self.config.log_data {
384            let levels = self.config.book_levels_to_print;
385            let instrument_id = book.instrument_id;
386            let book_str = book.pprint(levels, None);
387            log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
388        }
389
390        Ok(())
391    }
392
393    fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
394        if self.config.manage_book {
395            if let Some(book) = self.books.get_mut(&deltas.instrument_id) {
396                book.apply_deltas(deltas)?;
397
398                if self.config.log_data {
399                    let levels = self.config.book_levels_to_print;
400                    let instrument_id = deltas.instrument_id;
401                    let book_str = book.pprint(levels, None);
402                    log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
403                }
404            }
405        } else if self.config.log_data {
406            log_info!("{deltas:?}", color = LogColor::Cyan);
407        }
408        Ok(())
409    }
410
411    fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
412        if self.config.log_data {
413            log_info!("{quote:?}", color = LogColor::Cyan);
414        }
415        Ok(())
416    }
417
418    fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
419        if self.config.log_data {
420            log_info!("{trade:?}", color = LogColor::Cyan);
421        }
422        Ok(())
423    }
424
425    fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
426        if self.config.log_data {
427            log_info!("{bar:?}", color = LogColor::Cyan);
428        }
429        Ok(())
430    }
431
432    fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
433        if self.config.log_data {
434            log_info!("{mark_price:?}", color = LogColor::Cyan);
435        }
436        Ok(())
437    }
438
439    fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
440        if self.config.log_data {
441            log_info!("{index_price:?}", color = LogColor::Cyan);
442        }
443        Ok(())
444    }
445
446    fn on_funding_rate(&mut self, funding_rate: &FundingRateUpdate) -> anyhow::Result<()> {
447        if self.config.log_data {
448            log_info!("{funding_rate:?}", color = LogColor::Cyan);
449        }
450        Ok(())
451    }
452
453    fn on_instrument_status(&mut self, data: &InstrumentStatus) -> anyhow::Result<()> {
454        if self.config.log_data {
455            log_info!("{data:?}", color = LogColor::Cyan);
456        }
457        Ok(())
458    }
459
460    fn on_instrument_close(&mut self, update: &InstrumentClose) -> anyhow::Result<()> {
461        if self.config.log_data {
462            log_info!("{update:?}", color = LogColor::Cyan);
463        }
464        Ok(())
465    }
466
467    fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
468        if self.config.log_data {
469            log_info!("{greeks:?}", color = LogColor::Cyan);
470        }
471        Ok(())
472    }
473
474    fn on_historical_trades(&mut self, trades: &[TradeTick]) -> anyhow::Result<()> {
475        if self.config.log_data {
476            log_info!(
477                "Received {} historical trades",
478                trades.len(),
479                color = LogColor::Cyan
480            );
481
482            for trade in trades.iter().take(5) {
483                log_info!("  {trade:?}", color = LogColor::Cyan);
484            }
485
486            if trades.len() > 5 {
487                log_info!(
488                    "  ... and {} more trades",
489                    trades.len() - 5,
490                    color = LogColor::Cyan
491                );
492            }
493        }
494        Ok(())
495    }
496
497    fn on_historical_quotes(&mut self, quotes: &[QuoteTick]) -> anyhow::Result<()> {
498        if self.config.log_data {
499            log_info!(
500                "Received {} historical quotes",
501                quotes.len(),
502                color = LogColor::Cyan
503            );
504
505            for quote in quotes.iter().take(5) {
506                log_info!("  {quote:?}", color = LogColor::Cyan);
507            }
508
509            if quotes.len() > 5 {
510                log_info!(
511                    "  ... and {} more quotes",
512                    quotes.len() - 5,
513                    color = LogColor::Cyan
514                );
515            }
516        }
517        Ok(())
518    }
519
520    fn on_historical_funding_rates(
521        &mut self,
522        funding_rates: &[FundingRateUpdate],
523    ) -> anyhow::Result<()> {
524        if self.config.log_data {
525            log_info!(
526                "Received {} historical funding rates",
527                funding_rates.len(),
528                color = LogColor::Cyan
529            );
530
531            for rate in funding_rates.iter().take(5) {
532                log_info!("  {rate:?}", color = LogColor::Cyan);
533            }
534
535            if funding_rates.len() > 5 {
536                log_info!(
537                    "  ... and {} more funding rates",
538                    funding_rates.len() - 5,
539                    color = LogColor::Cyan
540                );
541            }
542        }
543        Ok(())
544    }
545
546    fn on_historical_bars(&mut self, bars: &[Bar]) -> anyhow::Result<()> {
547        if self.config.log_data {
548            log_info!(
549                "Received {} historical bars",
550                bars.len(),
551                color = LogColor::Cyan
552            );
553
554            for bar in bars.iter().take(5) {
555                log_info!("  {bar:?}", color = LogColor::Cyan);
556            }
557
558            if bars.len() > 5 {
559                log_info!(
560                    "  ... and {} more bars",
561                    bars.len() - 5,
562                    color = LogColor::Cyan
563                );
564            }
565        }
566        Ok(())
567    }
568}
569
570impl DataTester {
571    /// Creates a new [`DataTester`] instance.
572    #[must_use]
573    pub fn new(config: DataTesterConfig) -> Self {
574        Self {
575            core: DataActorCore::new(config.base.clone()),
576            config,
577            books: AHashMap::new(),
578        }
579    }
580}