1use 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#[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 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 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 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 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 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 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 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 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 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 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_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 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 #[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}