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