1use std::{num::NonZeroUsize, time::Duration};
17
18use ahash::{AHashMap, AHashSet};
19use jiff::SignedDuration;
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::{Instrument, InstrumentAny},
35 orderbook::OrderBook,
36};
37
38use super::config::DataTesterConfig;
39use crate::testers::timestamps::{warn_if_implausible_optional, warn_if_implausible_unix_nanos};
40
41#[derive(Debug)]
50pub struct DataTester {
51 pub(super) core: DataActorCore,
52 pub(super) config: DataTesterConfig,
53 pub(super) books: AHashMap<InstrumentId, OrderBook>,
54}
55
56nautilus_actor!(DataTester);
57
58impl DataActor for DataTester {
59 #[expect(
60 clippy::too_many_lines,
61 reason = "startup subscribes to each configured data scenario explicitly"
62 )]
63 fn on_start(&mut self) -> anyhow::Result<()> {
64 let instrument_ids = self.config.instrument_ids.clone();
65 let client_id = self.config.client_id;
66 let subscribe_params = self.config.subscribe_params.clone();
67 let request_params = self.config.request_params.clone();
68 let stats_interval_secs = self.config.stats_interval_secs;
69
70 if self.config.request_instruments {
72 let mut venues = AHashSet::new();
73 for instrument_id in &instrument_ids {
74 venues.insert(instrument_id.venue);
75 }
76
77 for venue in venues {
78 let _ = self.request_instruments(
79 Some(venue),
80 None,
81 None,
82 client_id,
83 request_params.clone(),
84 );
85 }
86 }
87
88 for instrument_id in instrument_ids {
90 if self.config.subscribe_instrument {
91 self.subscribe_instrument(instrument_id, client_id, subscribe_params.clone());
92 }
93
94 if self.config.subscribe_book_deltas {
95 self.subscribe_book_deltas(
96 instrument_id,
97 self.config.book_type,
98 None,
99 client_id,
100 self.config.manage_book,
101 subscribe_params.clone(),
102 );
103
104 if self.config.manage_book {
105 let book = OrderBook::new(instrument_id, self.config.book_type);
106 self.books.insert(instrument_id, book);
107 }
108 }
109
110 if self.config.subscribe_book_at_interval {
111 self.subscribe_book_at_interval(
112 instrument_id,
113 self.config.book_type,
114 self.config
115 .book_depth
116 .map(|depth| {
117 NonZeroUsize::new(depth).ok_or_else(|| {
118 ConfigError::range("book_depth", "must be positive, was 0")
119 })
120 })
121 .transpose()?,
122 NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
123 ConfigError::range("book_interval_ms", "must be positive, was 0")
124 })?,
125 client_id,
126 subscribe_params.clone(),
127 );
128 }
129
130 if self.config.subscribe_book_depth {
131 self.subscribe_book_depth10(
132 instrument_id,
133 self.config.book_type,
134 client_id,
135 self.config.manage_book,
136 subscribe_params.clone(),
137 );
138 }
139
140 if self.config.subscribe_quotes {
141 self.subscribe_quotes(instrument_id, client_id, subscribe_params.clone());
142 }
143
144 if self.config.subscribe_trades {
145 self.subscribe_trades(instrument_id, client_id, subscribe_params.clone());
146 }
147
148 if self.config.subscribe_mark_prices {
149 self.subscribe_mark_prices(instrument_id, client_id, subscribe_params.clone());
150 }
151
152 if self.config.subscribe_index_prices {
153 self.subscribe_index_prices(instrument_id, client_id, subscribe_params.clone());
154 }
155
156 if self.config.subscribe_funding_rates {
157 self.subscribe_funding_rates(instrument_id, client_id, subscribe_params.clone());
158 }
159
160 if self.config.subscribe_instrument_status {
161 self.subscribe_instrument_status(
162 instrument_id,
163 client_id,
164 subscribe_params.clone(),
165 );
166 }
167
168 if self.config.subscribe_instrument_close {
169 self.subscribe_instrument_close(instrument_id, client_id, subscribe_params.clone());
170 }
171
172 if self.config.subscribe_option_greeks {
173 self.subscribe_option_greeks(instrument_id, client_id, subscribe_params.clone());
174 }
175
176 if self.config.request_quotes {
178 let start = self.clock().utc_now() - SignedDuration::from_hours(1);
179
180 if let Err(e) = self.request_quotes(
181 instrument_id,
182 Some(start),
183 None,
184 None,
185 client_id,
186 request_params.clone(),
187 ) {
188 log::error!("Failed to request quotes for {instrument_id}: {e}");
189 }
190 }
191
192 if self.config.request_book_snapshot {
194 let _ = self.request_book_snapshot(
195 instrument_id,
196 self.config
197 .book_depth
198 .map(|depth| {
199 NonZeroUsize::new(depth).ok_or_else(|| {
200 ConfigError::range("book_depth", "must be positive, was 0")
201 })
202 })
203 .transpose()?,
204 client_id,
205 request_params.clone(),
206 );
207 }
208
209 if self.config.request_trades {
213 let start = self.clock().utc_now() - SignedDuration::from_hours(1);
214
215 if let Err(e) = self.request_trades(
216 instrument_id,
217 Some(start),
218 None,
219 None,
220 client_id,
221 request_params.clone(),
222 ) {
223 log::error!("Failed to request trades for {instrument_id}: {e}");
224 }
225 }
226
227 if self.config.request_funding_rates {
229 let start = self.clock().utc_now() - SignedDuration::from_hours(7 * 24);
230
231 if let Err(e) = self.request_funding_rates(
232 instrument_id,
233 Some(start),
234 None,
235 None,
236 client_id,
237 request_params.clone(),
238 ) {
239 log::error!("Failed to request funding rates for {instrument_id}: {e}");
240 }
241 }
242 }
243
244 if let Some(bar_types) = self.config.bar_types.clone() {
246 for bar_type in bar_types {
247 if self.config.subscribe_bars {
248 self.subscribe_bars(bar_type, client_id, subscribe_params.clone());
249 }
250
251 if self.config.request_bars {
253 let start = self.clock().utc_now() - SignedDuration::from_hours(1);
254
255 if let Err(e) = self.request_bars(
256 bar_type,
257 Some(start),
258 None,
259 None,
260 client_id,
261 request_params.clone(),
262 ) {
263 log::error!("Failed to request bars for {bar_type}: {e}");
264 }
265 }
266 }
267 }
268
269 if stats_interval_secs > 0 {
271 self.clock().set_timer(
272 "STATS-TIMER",
273 Duration::from_secs(stats_interval_secs),
274 None,
275 None,
276 None,
277 Some(true),
278 Some(false),
279 )?;
280 }
281
282 Ok(())
283 }
284
285 fn on_stop(&mut self) -> anyhow::Result<()> {
286 if !self.config.can_unsubscribe {
287 return Ok(());
288 }
289
290 let instrument_ids = self.config.instrument_ids.clone();
291 let client_id = self.config.client_id;
292 let subscribe_params = self.config.subscribe_params.clone();
293
294 for instrument_id in instrument_ids {
295 if self.config.subscribe_instrument {
296 self.unsubscribe_instrument(instrument_id, client_id, subscribe_params.clone());
297 }
298
299 if self.config.subscribe_book_deltas {
300 self.unsubscribe_book_deltas(instrument_id, client_id, subscribe_params.clone());
301 }
302
303 if self.config.subscribe_book_at_interval {
304 self.unsubscribe_book_at_interval(
305 instrument_id,
306 NonZeroUsize::new(self.config.book_interval_ms).ok_or_else(|| {
307 ConfigError::range("book_interval_ms", "must be positive, was 0")
308 })?,
309 client_id,
310 subscribe_params.clone(),
311 );
312 }
313
314 if self.config.subscribe_book_depth {
315 self.unsubscribe_book_depth10(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 Ok(())
373 }
374
375 fn on_instrument(&mut self, instrument: &InstrumentAny) -> anyhow::Result<()> {
376 warn_if_implausible_unix_nanos("instrument", instrument.ts_event(), instrument.ts_init());
377
378 if self.config.log_data {
379 log_info!("{instrument:?}", color = LogColor::Cyan);
380 }
381 Ok(())
382 }
383
384 fn on_book(&mut self, book: &OrderBook) -> anyhow::Result<()> {
385 if self.config.log_data {
386 let levels = self.config.book_levels_to_print;
387 let instrument_id = book.instrument_id;
388 let book_str = book.pprint(levels, None);
389 log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
390 }
391
392 Ok(())
393 }
394
395 fn on_book_deltas(&mut self, deltas: &OrderBookDeltas) -> anyhow::Result<()> {
396 warn_if_implausible_unix_nanos("book deltas", deltas.ts_event, deltas.ts_init);
397
398 if self.config.manage_book {
399 if let Some(book) = self.books.get_mut(&deltas.instrument_id) {
400 book.apply_deltas(deltas)?;
401
402 if self.config.log_data {
403 let levels = self.config.book_levels_to_print;
404 let instrument_id = deltas.instrument_id;
405 let book_str = book.pprint(levels, None);
406 log_info!("\n{instrument_id}\n{book_str}", color = LogColor::Cyan);
407 }
408 }
409 } else if self.config.log_data {
410 log_info!("{deltas:?}", color = LogColor::Cyan);
411 }
412 Ok(())
413 }
414
415 fn on_book_depth(&mut self, depth: &OrderBookDepth10) -> anyhow::Result<()> {
416 warn_if_implausible_unix_nanos("book depth", depth.ts_event, depth.ts_init);
417
418 if self.config.log_data {
419 log_info!("{depth:?}", color = LogColor::Cyan);
420 }
421 Ok(())
422 }
423
424 fn on_quote(&mut self, quote: &QuoteTick) -> anyhow::Result<()> {
425 warn_if_implausible_unix_nanos("quote", quote.ts_event, quote.ts_init);
426
427 if self.config.log_data {
428 log_info!("{quote:?}", color = LogColor::Cyan);
429 }
430 Ok(())
431 }
432
433 fn on_trade(&mut self, trade: &TradeTick) -> anyhow::Result<()> {
434 warn_if_implausible_unix_nanos("trade", trade.ts_event, trade.ts_init);
435
436 if self.config.log_data {
437 log_info!("{trade:?}", color = LogColor::Cyan);
438 }
439 Ok(())
440 }
441
442 fn on_bar(&mut self, bar: &Bar) -> anyhow::Result<()> {
443 warn_if_implausible_unix_nanos("bar", bar.ts_event, bar.ts_init);
444
445 if self.config.log_data {
446 log_info!("{bar:?}", color = LogColor::Cyan);
447 }
448 Ok(())
449 }
450
451 fn on_mark_price(&mut self, mark_price: &MarkPriceUpdate) -> anyhow::Result<()> {
452 warn_if_implausible_unix_nanos("mark price", mark_price.ts_event, mark_price.ts_init);
453
454 if self.config.log_data {
455 log_info!("{mark_price:?}", color = LogColor::Cyan);
456 }
457 Ok(())
458 }
459
460 fn on_index_price(&mut self, index_price: &IndexPriceUpdate) -> anyhow::Result<()> {
461 warn_if_implausible_unix_nanos("index price", index_price.ts_event, index_price.ts_init);
462
463 if self.config.log_data {
464 log_info!("{index_price:?}", color = LogColor::Cyan);
465 }
466 Ok(())
467 }
468
469 fn on_funding_rate(&mut self, funding_rate: &FundingRateUpdate) -> anyhow::Result<()> {
470 warn_if_implausible_unix_nanos("funding rate", funding_rate.ts_event, funding_rate.ts_init);
471 warn_if_implausible_optional(
472 "funding rate",
473 "next_funding_ns",
474 funding_rate.next_funding_ns,
475 );
476
477 if self.config.log_data {
478 log_info!("{funding_rate:?}", color = LogColor::Cyan);
479 }
480 Ok(())
481 }
482
483 fn on_instrument_status(&mut self, data: &InstrumentStatus) -> anyhow::Result<()> {
484 warn_if_implausible_unix_nanos("instrument status", data.ts_event, data.ts_init);
485
486 if self.config.log_data {
487 log_info!("{data:?}", color = LogColor::Cyan);
488 }
489 Ok(())
490 }
491
492 fn on_instrument_close(&mut self, update: &InstrumentClose) -> anyhow::Result<()> {
493 warn_if_implausible_unix_nanos("instrument close", update.ts_event, update.ts_init);
494
495 if self.config.log_data {
496 log_info!("{update:?}", color = LogColor::Cyan);
497 }
498 Ok(())
499 }
500
501 fn on_option_greeks(&mut self, greeks: &OptionGreeks) -> anyhow::Result<()> {
502 warn_if_implausible_unix_nanos("option greeks", greeks.ts_event, greeks.ts_init);
503
504 if self.config.log_data {
505 log_info!("{greeks:?}", color = LogColor::Cyan);
506 }
507 Ok(())
508 }
509
510 fn on_historical_trades(&mut self, trades: &[TradeTick]) -> anyhow::Result<()> {
511 for trade in trades {
512 warn_if_implausible_unix_nanos("historical trade", trade.ts_event, trade.ts_init);
513 }
514
515 if self.config.log_data {
516 log_info!(
517 "Received {} historical trades",
518 trades.len(),
519 color = LogColor::Cyan
520 );
521
522 for trade in trades.iter().take(5) {
523 log_info!(" {trade:?}", color = LogColor::Cyan);
524 }
525
526 if trades.len() > 5 {
527 log_info!(
528 " ... and {} more trades",
529 trades.len() - 5,
530 color = LogColor::Cyan
531 );
532 }
533 }
534 Ok(())
535 }
536
537 fn on_historical_quotes(&mut self, quotes: &[QuoteTick]) -> anyhow::Result<()> {
538 for quote in quotes {
539 warn_if_implausible_unix_nanos("historical quote", quote.ts_event, quote.ts_init);
540 }
541
542 if self.config.log_data {
543 log_info!(
544 "Received {} historical quotes",
545 quotes.len(),
546 color = LogColor::Cyan
547 );
548
549 for quote in quotes.iter().take(5) {
550 log_info!(" {quote:?}", color = LogColor::Cyan);
551 }
552
553 if quotes.len() > 5 {
554 log_info!(
555 " ... and {} more quotes",
556 quotes.len() - 5,
557 color = LogColor::Cyan
558 );
559 }
560 }
561 Ok(())
562 }
563
564 fn on_historical_funding_rates(
565 &mut self,
566 funding_rates: &[FundingRateUpdate],
567 ) -> anyhow::Result<()> {
568 for rate in funding_rates {
569 warn_if_implausible_unix_nanos("historical funding rate", rate.ts_event, rate.ts_init);
570 warn_if_implausible_optional(
571 "historical funding rate",
572 "next_funding_ns",
573 rate.next_funding_ns,
574 );
575 }
576
577 if self.config.log_data {
578 log_info!(
579 "Received {} historical funding rates",
580 funding_rates.len(),
581 color = LogColor::Cyan
582 );
583
584 for rate in funding_rates.iter().take(5) {
585 log_info!(" {rate:?}", color = LogColor::Cyan);
586 }
587
588 if funding_rates.len() > 5 {
589 log_info!(
590 " ... and {} more funding rates",
591 funding_rates.len() - 5,
592 color = LogColor::Cyan
593 );
594 }
595 }
596 Ok(())
597 }
598
599 fn on_historical_bars(&mut self, bars: &[Bar]) -> anyhow::Result<()> {
600 for bar in bars {
601 warn_if_implausible_unix_nanos("historical bar", bar.ts_event, bar.ts_init);
602 }
603
604 if self.config.log_data {
605 log_info!(
606 "Received {} historical bars",
607 bars.len(),
608 color = LogColor::Cyan
609 );
610
611 for bar in bars.iter().take(5) {
612 log_info!(" {bar:?}", color = LogColor::Cyan);
613 }
614
615 if bars.len() > 5 {
616 log_info!(
617 " ... and {} more bars",
618 bars.len() - 5,
619 color = LogColor::Cyan
620 );
621 }
622 }
623 Ok(())
624 }
625}
626
627impl DataTester {
628 #[must_use]
630 pub fn new(config: DataTesterConfig) -> Self {
631 Self {
632 core: DataActorCore::new(config.base.clone()),
633 config,
634 books: AHashMap::new(),
635 }
636 }
637}