1use std::{
17 fs,
18 path::{Path, PathBuf},
19};
20
21use ahash::{AHashMap, AHashSet};
22use anyhow::Context;
23use arrow::record_batch::RecordBatch;
24use futures_util::{StreamExt, pin_mut};
25use jiff::{Timestamp, civil::Date, tz::Offset};
26use nautilus_core::{UnixNanos, datetime::unix_nanos_to_iso8601, string::formatting::Separable};
27use nautilus_model::{
28 data::{
29 Bar, BarType, CatalogPathPrefix, Data, OptionGreeks, OrderBookDelta, OrderBookDeltas,
30 OrderBookDepth10, QuoteTick, TradeTick,
31 },
32 identifiers::InstrumentId,
33};
34use nautilus_serialization::arrow::{
35 bars_to_arrow_record_batch_bytes, book_deltas_to_arrow_record_batch_bytes,
36 book_depth10_to_arrow_record_batch_bytes, option_greeks_to_arrow_record_batch_bytes,
37 quotes_to_arrow_record_batch_bytes, trades_to_arrow_record_batch_bytes,
38};
39use parquet::{arrow::ArrowWriter, basic::Compression, file::properties::WriterProperties};
40
41use crate::{
42 config::{BookSnapshotOutput, ParquetCompression, TardisReplayConfig},
43 http::TardisHttpClient,
44 machine::TardisMachineClient,
45};
46
47struct DateCursor {
48 date_utc: Date,
50 end_ns: UnixNanos,
52}
53
54impl DateCursor {
55 fn new(current_ns: UnixNanos) -> Self {
57 let current_utc = current_ns.to_datetime_utc();
58 let date_utc = Offset::UTC.to_datetime(current_utc).date();
59
60 let end_utc = utc_timestamp(date_utc, 23, 59, 59, 999_999_999);
62 let end_ns = UnixNanos::from(u64::try_from(end_utc.as_nanosecond()).unwrap_or(u64::MAX));
63
64 Self { date_utc, end_ns }
65 }
66}
67
68fn utc_timestamp(date: Date, hour: i8, minute: i8, second: i8, nanosecond: i32) -> Timestamp {
69 Offset::UTC
70 .to_timestamp(date.at(hour, minute, second, nanosecond))
71 .expect("valid UTC civil datetime")
72}
73
74pub async fn run_tardis_machine_replay_from_config(config_filepath: &Path) -> anyhow::Result<()> {
85 log::debug!("Starting replay");
86 log::debug!("Config filepath: {}", config_filepath.display());
87
88 let config_data = fs::read_to_string(config_filepath)
90 .with_context(|| format!("Failed to read config file: {}", config_filepath.display()))?;
91 let config: TardisReplayConfig = serde_json::from_str(&config_data)
92 .context("failed to parse config JSON into TardisReplayConfig")?;
93
94 let path = config
95 .output_path
96 .as_deref()
97 .map(Path::new)
98 .map(Path::to_path_buf)
99 .or_else(|| {
100 std::env::var("NAUTILUS_PATH")
101 .ok()
102 .map(|env_path| PathBuf::from(env_path).join("catalog").join("data"))
103 })
104 .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
105
106 log::debug!("Output path: {}", path.display());
107
108 let normalize_symbols = config.normalize_symbols.unwrap_or(true);
109 log::debug!("normalize_symbols={normalize_symbols}");
110
111 let book_snapshot_output = config
112 .book_snapshot_output
113 .clone()
114 .unwrap_or(BookSnapshotOutput::Deltas);
115 log::debug!("book_snapshot_output={book_snapshot_output:?}");
116
117 let extract_bbo_as_quotes = config.extract_bbo_as_quotes.unwrap_or(false);
118 log::debug!("extract_bbo_as_quotes={extract_bbo_as_quotes}");
119
120 let compression = config
121 .compression
122 .clone()
123 .unwrap_or(ParquetCompression::Zstd);
124 log::debug!("compression={compression:?}");
125 let compression = compression.as_parquet_compression();
126
127 let http_client = TardisHttpClient::new(
128 None,
129 None,
130 None,
131 normalize_symbols,
132 config.proxy_url.clone(),
133 )?;
134 let mut machine_client = TardisMachineClient::new(
135 config.tardis_ws_url.as_deref(),
136 normalize_symbols,
137 book_snapshot_output,
138 )?;
139 machine_client.extract_bbo_as_quotes = extract_bbo_as_quotes;
140
141 let exchanges: AHashSet<_> = config.options.iter().map(|opt| opt.exchange).collect();
142 let (instrument_map, _instruments) = http_client
143 .bootstrap_instruments(&exchanges)
144 .await
145 .context("failed to bootstrap instruments")?;
146
147 for (_, info) in &instrument_map {
148 machine_client.add_instrument_info((**info).clone());
149 }
150
151 log::debug!("Starting tardis-machine stream");
152 let stream = machine_client.replay(config.options).await?;
153 pin_mut!(stream);
154
155 let mut deltas_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
157 let mut depths_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
158 let mut quotes_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
159 let mut trades_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
160 let mut bars_cursors: AHashMap<BarType, DateCursor> = AHashMap::new();
161 let mut greeks_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
162
163 let mut deltas_map: AHashMap<InstrumentId, Vec<OrderBookDelta>> = AHashMap::new();
165 let mut depths_map: AHashMap<InstrumentId, Vec<OrderBookDepth10>> = AHashMap::new();
166 let mut quotes_map: AHashMap<InstrumentId, Vec<QuoteTick>> = AHashMap::new();
167 let mut trades_map: AHashMap<InstrumentId, Vec<TradeTick>> = AHashMap::new();
168 let mut bars_map: AHashMap<BarType, Vec<Bar>> = AHashMap::new();
169 let mut greeks_map: AHashMap<InstrumentId, Vec<OptionGreeks>> = AHashMap::new();
170
171 let mut msg_count = 0;
172
173 while let Some(result) = stream.next().await {
174 match result {
175 Ok(msg) => {
176 match msg {
177 Data::Deltas(msg) => {
178 handle_deltas_msg(
179 &msg,
180 &mut deltas_map,
181 &mut deltas_cursors,
182 &path,
183 compression,
184 );
185 }
186 Data::Depth10(msg) => {
187 handle_depth10_msg(
188 *msg,
189 &mut depths_map,
190 &mut depths_cursors,
191 &path,
192 compression,
193 );
194 }
195 Data::Quote(msg) => {
196 handle_quote_msg(
197 msg,
198 &mut quotes_map,
199 &mut quotes_cursors,
200 &path,
201 compression,
202 );
203 }
204 Data::Trade(msg) => {
205 handle_trade_msg(
206 msg,
207 &mut trades_map,
208 &mut trades_cursors,
209 &path,
210 compression,
211 );
212 }
213 Data::Bar(msg) => {
214 handle_bar_msg(msg, &mut bars_map, &mut bars_cursors, &path, compression);
215 }
216 Data::Delta(delta) => {
217 log::warn!(
218 "Skipping individual delta message for {} (use Deltas batch instead)",
219 delta.instrument_id
220 );
221 }
222 Data::OptionGreeks(msg) => {
223 handle_option_greeks_msg(
224 msg,
225 &mut greeks_map,
226 &mut greeks_cursors,
227 &path,
228 compression,
229 );
230 }
231 Data::MarkPrice(_)
232 | Data::IndexPrice(_)
233 | Data::FundingRate(_)
234 | Data::InstrumentStatus(_)
235 | Data::InstrumentClose(_)
236 | Data::Custom(_) => {
237 log::debug!(
238 "Skipping unsupported data type for instrument {}",
239 msg.instrument_id()
240 );
241 }
242 #[allow(unreachable_patterns)]
243 _ => {
244 log::debug!("Skipping unsupported data type");
245 }
246 }
247
248 msg_count += 1;
249 if msg_count % 100_000 == 0 {
250 log::debug!("Processed {} messages", msg_count.separate_with_commas());
251 }
252 }
253 Err(e) => {
254 log::error!("Stream error: {e:?}");
255 break;
256 }
257 }
258 }
259
260 for (instrument_id, deltas) in &deltas_map {
263 let cursor = deltas_cursors.get(instrument_id).expect("Expected cursor");
264 batch_and_write_deltas(deltas, instrument_id, cursor.date_utc, &path, compression);
265 }
266
267 for (instrument_id, depths) in &depths_map {
268 let cursor = depths_cursors.get(instrument_id).expect("Expected cursor");
269 batch_and_write_depths(depths, instrument_id, cursor.date_utc, &path, compression);
270 }
271
272 for (instrument_id, quotes) in "es_map {
273 let cursor = quotes_cursors.get(instrument_id).expect("Expected cursor");
274 batch_and_write_quotes(quotes, instrument_id, cursor.date_utc, &path, compression);
275 }
276
277 for (instrument_id, trades) in &trades_map {
278 let cursor = trades_cursors.get(instrument_id).expect("Expected cursor");
279 batch_and_write_trades(trades, instrument_id, cursor.date_utc, &path, compression);
280 }
281
282 for (bar_type, bars) in &bars_map {
283 let cursor = bars_cursors.get(bar_type).expect("Expected cursor");
284 batch_and_write_bars(bars, bar_type, cursor.date_utc, &path, compression);
285 }
286
287 for (instrument_id, greeks) in &greeks_map {
288 let cursor = greeks_cursors.get(instrument_id).expect("Expected cursor");
289 batch_and_write_greeks(greeks, instrument_id, cursor.date_utc, &path, compression);
290 }
291
292 log::debug!(
293 "Replay completed after {} messages",
294 msg_count.separate_with_commas()
295 );
296 Ok(())
297}
298
299fn handle_deltas_msg(
300 deltas: &OrderBookDeltas,
301 map: &mut AHashMap<InstrumentId, Vec<OrderBookDelta>>,
302 cursors: &mut AHashMap<InstrumentId, DateCursor>,
303 path: &Path,
304 compression: Compression,
305) {
306 let cursor = cursors
307 .entry(deltas.instrument_id)
308 .or_insert_with(|| DateCursor::new(deltas.ts_init));
309
310 if deltas.ts_init > cursor.end_ns {
311 if let Some(deltas_vec) = map.remove(&deltas.instrument_id) {
312 batch_and_write_deltas(
313 &deltas_vec,
314 &deltas.instrument_id,
315 cursor.date_utc,
316 path,
317 compression,
318 );
319 }
320 *cursor = DateCursor::new(deltas.ts_init);
322 }
323
324 map.entry(deltas.instrument_id)
325 .or_insert_with(|| Vec::with_capacity(100_000))
326 .extend(&*deltas.deltas);
327}
328
329fn handle_depth10_msg(
330 depth10: OrderBookDepth10,
331 map: &mut AHashMap<InstrumentId, Vec<OrderBookDepth10>>,
332 cursors: &mut AHashMap<InstrumentId, DateCursor>,
333 path: &Path,
334 compression: Compression,
335) {
336 let cursor = cursors
337 .entry(depth10.instrument_id)
338 .or_insert_with(|| DateCursor::new(depth10.ts_init));
339
340 if depth10.ts_init > cursor.end_ns {
341 if let Some(depths_vec) = map.remove(&depth10.instrument_id) {
342 batch_and_write_depths(
343 &depths_vec,
344 &depth10.instrument_id,
345 cursor.date_utc,
346 path,
347 compression,
348 );
349 }
350 *cursor = DateCursor::new(depth10.ts_init);
352 }
353
354 map.entry(depth10.instrument_id)
355 .or_insert_with(|| Vec::with_capacity(100_000))
356 .push(depth10);
357}
358
359fn handle_quote_msg(
360 quote: QuoteTick,
361 map: &mut AHashMap<InstrumentId, Vec<QuoteTick>>,
362 cursors: &mut AHashMap<InstrumentId, DateCursor>,
363 path: &Path,
364 compression: Compression,
365) {
366 let cursor = cursors
367 .entry(quote.instrument_id)
368 .or_insert_with(|| DateCursor::new(quote.ts_init));
369
370 if quote.ts_init > cursor.end_ns {
371 if let Some(quotes_vec) = map.remove("e.instrument_id) {
372 batch_and_write_quotes(
373 "es_vec,
374 "e.instrument_id,
375 cursor.date_utc,
376 path,
377 compression,
378 );
379 }
380 *cursor = DateCursor::new(quote.ts_init);
382 }
383
384 map.entry(quote.instrument_id)
385 .or_insert_with(|| Vec::with_capacity(100_000))
386 .push(quote);
387}
388
389fn handle_trade_msg(
390 trade: TradeTick,
391 map: &mut AHashMap<InstrumentId, Vec<TradeTick>>,
392 cursors: &mut AHashMap<InstrumentId, DateCursor>,
393 path: &Path,
394 compression: Compression,
395) {
396 let cursor = cursors
397 .entry(trade.instrument_id)
398 .or_insert_with(|| DateCursor::new(trade.ts_init));
399
400 if trade.ts_init > cursor.end_ns {
401 if let Some(trades_vec) = map.remove(&trade.instrument_id) {
402 batch_and_write_trades(
403 &trades_vec,
404 &trade.instrument_id,
405 cursor.date_utc,
406 path,
407 compression,
408 );
409 }
410 *cursor = DateCursor::new(trade.ts_init);
412 }
413
414 map.entry(trade.instrument_id)
415 .or_insert_with(|| Vec::with_capacity(100_000))
416 .push(trade);
417}
418
419fn handle_bar_msg(
420 bar: Bar,
421 map: &mut AHashMap<BarType, Vec<Bar>>,
422 cursors: &mut AHashMap<BarType, DateCursor>,
423 path: &Path,
424 compression: Compression,
425) {
426 let cursor = cursors
427 .entry(bar.bar_type)
428 .or_insert_with(|| DateCursor::new(bar.ts_init));
429
430 if bar.ts_init > cursor.end_ns {
431 if let Some(bars_vec) = map.remove(&bar.bar_type) {
432 batch_and_write_bars(&bars_vec, &bar.bar_type, cursor.date_utc, path, compression);
433 }
434 *cursor = DateCursor::new(bar.ts_init);
436 }
437
438 map.entry(bar.bar_type)
439 .or_insert_with(|| Vec::with_capacity(100_000))
440 .push(bar);
441}
442
443fn handle_option_greeks_msg(
444 greeks: OptionGreeks,
445 map: &mut AHashMap<InstrumentId, Vec<OptionGreeks>>,
446 cursors: &mut AHashMap<InstrumentId, DateCursor>,
447 path: &Path,
448 compression: Compression,
449) {
450 let cursor = cursors
451 .entry(greeks.instrument_id)
452 .or_insert_with(|| DateCursor::new(greeks.ts_init));
453
454 if greeks.ts_init > cursor.end_ns {
455 if let Some(greeks_vec) = map.remove(&greeks.instrument_id) {
456 batch_and_write_greeks(
457 &greeks_vec,
458 &greeks.instrument_id,
459 cursor.date_utc,
460 path,
461 compression,
462 );
463 }
464 *cursor = DateCursor::new(greeks.ts_init);
466 }
467
468 map.entry(greeks.instrument_id)
469 .or_insert_with(|| Vec::with_capacity(100_000))
470 .push(greeks);
471}
472
473fn batch_and_write_deltas(
474 deltas: &[OrderBookDelta],
475 instrument_id: &InstrumentId,
476 date: Date,
477 path: &Path,
478 compression: Compression,
479) {
480 match book_deltas_to_arrow_record_batch_bytes(deltas) {
481 Ok(batch) => write_batch(
482 &batch,
483 OrderBookDelta::path_prefix(),
484 instrument_id,
485 date,
486 path,
487 compression,
488 ),
489 Err(e) => {
490 log::error!("Error converting OrderBookDeltas to Arrow: {e:?}");
491 }
492 }
493}
494
495fn batch_and_write_depths(
496 depths: &[OrderBookDepth10],
497 instrument_id: &InstrumentId,
498 date: Date,
499 path: &Path,
500 compression: Compression,
501) {
502 match book_depth10_to_arrow_record_batch_bytes(depths) {
503 Ok(batch) => write_batch(
504 &batch,
505 OrderBookDepth10::path_prefix(),
506 instrument_id,
507 date,
508 path,
509 compression,
510 ),
511 Err(e) => {
512 log::error!("Error converting OrderBookDepth10 to Arrow: {e:?}");
513 }
514 }
515}
516
517fn batch_and_write_quotes(
518 quotes: &[QuoteTick],
519 instrument_id: &InstrumentId,
520 date: Date,
521 path: &Path,
522 compression: Compression,
523) {
524 match quotes_to_arrow_record_batch_bytes(quotes) {
525 Ok(batch) => write_batch(
526 &batch,
527 QuoteTick::path_prefix(),
528 instrument_id,
529 date,
530 path,
531 compression,
532 ),
533 Err(e) => {
534 log::error!("Error converting QuoteTick to Arrow: {e:?}");
535 }
536 }
537}
538
539fn batch_and_write_trades(
540 trades: &[TradeTick],
541 instrument_id: &InstrumentId,
542 date: Date,
543 path: &Path,
544 compression: Compression,
545) {
546 match trades_to_arrow_record_batch_bytes(trades) {
547 Ok(batch) => write_batch(
548 &batch,
549 TradeTick::path_prefix(),
550 instrument_id,
551 date,
552 path,
553 compression,
554 ),
555 Err(e) => {
556 log::error!("Error converting TradeTick to Arrow: {e:?}");
557 }
558 }
559}
560
561fn batch_and_write_bars(
562 bars: &[Bar],
563 bar_type: &BarType,
564 date: Date,
565 path: &Path,
566 compression: Compression,
567) {
568 let batch = match bars_to_arrow_record_batch_bytes(bars) {
569 Ok(batch) => batch,
570 Err(e) => {
571 log::error!("Error converting Bar to Arrow: {e:?}");
572 return;
573 }
574 };
575
576 let filepath = path.join(parquet_filepath_bars(bar_type, date));
577 if let Err(e) = write_parquet_local(&batch, &filepath, compression) {
578 log::error!("Error writing {}: {e}", filepath.display());
579 } else {
580 log::debug!("File written: {}", filepath.display());
581 }
582}
583
584fn batch_and_write_greeks(
585 greeks: &[OptionGreeks],
586 instrument_id: &InstrumentId,
587 date: Date,
588 path: &Path,
589 compression: Compression,
590) {
591 match option_greeks_to_arrow_record_batch_bytes(greeks) {
592 Ok(batch) => write_batch(
593 &batch,
594 OptionGreeks::path_prefix(),
595 instrument_id,
596 date,
597 path,
598 compression,
599 ),
600 Err(e) => {
601 log::error!("Error converting OptionGreeks to Arrow: {e:?}");
602 }
603 }
604}
605
606fn assert_post_epoch(date: Date) {
613 let epoch = Date::constant(1970, 1, 1);
614 assert!(
615 date >= epoch,
616 "Tardis replay filenames require dates on or after 1970-01-01; received {date}"
617 );
618}
619
620fn iso_timestamp_to_file_timestamp(iso_timestamp: &str) -> String {
625 iso_timestamp.replace([':', '.'], "-")
626}
627
628fn timestamps_to_filename(timestamp_1: UnixNanos, timestamp_2: UnixNanos) -> String {
633 let datetime_1 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_1));
634 let datetime_2 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_2));
635
636 format!("{datetime_1}_{datetime_2}.parquet")
637}
638
639fn parquet_filepath(typename: &str, instrument_id: &InstrumentId, date: Date) -> PathBuf {
640 assert_post_epoch(date);
641
642 let instrument_id_str = instrument_id.to_string().replace('/', "");
643
644 let start_nanos = utc_timestamp(date, 0, 0, 0, 0).as_nanosecond();
645 let end_nanos = utc_timestamp(date, 23, 59, 59, 999_999_999).as_nanosecond();
646
647 let filename = timestamps_to_filename(
648 UnixNanos::from(u64::try_from(start_nanos).expect("date fits UnixNanos")),
649 UnixNanos::from(u64::try_from(end_nanos).expect("date fits UnixNanos")),
650 );
651
652 PathBuf::new()
653 .join(typename)
654 .join(instrument_id_str)
655 .join(filename)
656}
657
658fn parquet_filepath_bars(bar_type: &BarType, date: Date) -> PathBuf {
659 assert_post_epoch(date);
660
661 let bar_type_str = bar_type.to_string().replace('/', "");
662
663 let start_nanos = utc_timestamp(date, 0, 0, 0, 0).as_nanosecond();
665 let end_nanos = utc_timestamp(date, 23, 59, 59, 999_999_999).as_nanosecond();
666
667 let filename = timestamps_to_filename(
668 UnixNanos::from(u64::try_from(start_nanos).expect("date fits UnixNanos")),
669 UnixNanos::from(u64::try_from(end_nanos).expect("date fits UnixNanos")),
670 );
671
672 PathBuf::new()
673 .join(Bar::path_prefix())
674 .join(bar_type_str)
675 .join(filename)
676}
677
678fn write_batch(
679 batch: &RecordBatch,
680 typename: &str,
681 instrument_id: &InstrumentId,
682 date: Date,
683 path: &Path,
684 compression: Compression,
685) {
686 let filepath = path.join(parquet_filepath(typename, instrument_id, date));
687 if let Err(e) = write_parquet_local(batch, &filepath, compression) {
688 log::error!("Error writing {}: {e}", filepath.display());
689 } else {
690 log::debug!("File written: {}", filepath.display());
691 }
692}
693
694fn write_parquet_local(
695 batch: &RecordBatch,
696 file_path: &Path,
697 compression: Compression,
698) -> anyhow::Result<()> {
699 if let Some(parent) = file_path.parent() {
700 std::fs::create_dir_all(parent)?;
701 }
702
703 let file = std::fs::File::create(file_path)?;
704 let props = WriterProperties::builder()
705 .set_compression(compression)
706 .build();
707
708 let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))?;
709 writer.write(batch)?;
710 writer.close()?;
711 Ok(())
712}
713
714#[cfg(test)]
715mod tests {
716 use std::sync::Arc;
717
718 use nautilus_persistence::backend::catalog::ParquetDataCatalog;
719 use rstest::rstest;
720
721 use super::*;
722 use crate::{
723 common::{enums::TardisExchange, testing::load_test_json},
724 config::BookSnapshotOutput,
725 machine::{
726 message::{BookSnapshotMsg, OptionSummaryMsg, TradeMsg, WsMessage},
727 parse::parse_tardis_ws_message,
728 types::TardisInstrumentMiniInfo,
729 },
730 };
731
732 fn utc_nanos(
733 year: i16,
734 month: i8,
735 day: i8,
736 hour: i8,
737 minute: i8,
738 second: i8,
739 nanosecond: i32,
740 ) -> u64 {
741 u64::try_from(
742 utc_timestamp(
743 Date::new(year, month, day).unwrap(),
744 hour,
745 minute,
746 second,
747 nanosecond,
748 )
749 .as_nanosecond(),
750 )
751 .unwrap()
752 }
753
754 #[rstest]
755 #[case(
756 utc_nanos(2024, 1, 1, 0, 0, 0, 0),
758 Date::new(2024, 1, 1).unwrap(),
759 utc_nanos(2024, 1, 1, 23, 59, 59, 999_999_999)
760)]
761 #[case(
762 utc_nanos(2024, 1, 1, 12, 0, 0, 0),
764 Date::new(2024, 1, 1).unwrap(),
765 utc_nanos(2024, 1, 1, 23, 59, 59, 999_999_999)
766)]
767 #[case(
768 utc_nanos(2024, 1, 1, 23, 59, 59, 999_999_999),
770 Date::new(2024, 1, 1).unwrap(),
771 utc_nanos(2024, 1, 1, 23, 59, 59, 999_999_999)
772)]
773 #[case(
774 utc_nanos(2024, 1, 2, 0, 0, 0, 0),
776 Date::new(2024, 1, 2).unwrap(),
777 utc_nanos(2024, 1, 2, 23, 59, 59, 999_999_999)
778)]
779 fn test_date_cursor(
780 #[case] timestamp: u64,
781 #[case] expected_date: Date,
782 #[case] expected_end_ns: u64,
783 ) {
784 let unix_nanos = UnixNanos::from(timestamp);
785 let cursor = DateCursor::new(unix_nanos);
786
787 assert_eq!(cursor.date_utc, expected_date);
788 assert_eq!(cursor.end_ns, UnixNanos::from(expected_end_ns));
789 }
790
791 #[rstest]
792 fn test_option_greeks_replay_catalog_round_trip() {
793 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
794 let compression = ParquetCompression::Zstd.as_parquet_compression();
795 let info = Arc::new(TardisInstrumentMiniInfo::new(
796 instrument_id,
797 None,
798 TardisExchange::Deribit,
799 4,
800 1,
801 ));
802
803 let option_summary: OptionSummaryMsg =
804 serde_json::from_str(&load_test_json("option_summary.json")).unwrap();
805 let Some(Data::OptionGreeks(greeks_1)) = parse_tardis_ws_message(
806 WsMessage::OptionSummary(option_summary),
807 &info,
808 &BookSnapshotOutput::Deltas,
809 ) else {
810 panic!("Expected option_summary to route to Data::OptionGreeks");
811 };
812
813 let mut greeks_2 = greeks_1;
814 greeks_2.ts_event = greeks_1.ts_event + 1_000_000_000;
815 greeks_2.ts_init = greeks_1.ts_init + 1_000_000_000;
816 greeks_2.greeks.delta = 0.26;
817
818 let option_quote: BookSnapshotMsg =
819 serde_json::from_str(&load_test_json("option_book_snapshot.json")).unwrap();
820 let Some(Data::Quote(quote_1)) = parse_tardis_ws_message(
821 WsMessage::BookSnapshot(option_quote),
822 &info,
823 &BookSnapshotOutput::Deltas,
824 ) else {
825 panic!("Expected depth-1 option book snapshot to route to Data::Quote");
826 };
827
828 let mut quote_2 = quote_1;
829 quote_2.ts_event = quote_1.ts_event + 1_000_000_000;
830 quote_2.ts_init = quote_1.ts_init + 1_000_000_000;
831
832 let temp_dir = tempfile::tempdir().unwrap();
833 let data_path = temp_dir.path().join("data");
834
835 let mut quotes_map: AHashMap<InstrumentId, Vec<QuoteTick>> = AHashMap::new();
836 let mut quotes_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
837 let mut greeks_map: AHashMap<InstrumentId, Vec<OptionGreeks>> = AHashMap::new();
838 let mut greeks_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
839
840 for quote in [quote_1, quote_2] {
841 handle_quote_msg(
842 quote,
843 &mut quotes_map,
844 &mut quotes_cursors,
845 &data_path,
846 compression,
847 );
848 }
849
850 for greeks in [greeks_1, greeks_2] {
851 handle_option_greeks_msg(
852 greeks,
853 &mut greeks_map,
854 &mut greeks_cursors,
855 &data_path,
856 compression,
857 );
858 }
859
860 for (id, quotes) in "es_map {
861 let cursor = quotes_cursors.get(id).expect("Expected cursor");
862 batch_and_write_quotes(quotes, id, cursor.date_utc, &data_path, compression);
863 }
864
865 for (id, greeks) in &greeks_map {
866 let cursor = greeks_cursors.get(id).expect("Expected cursor");
867 batch_and_write_greeks(greeks, id, cursor.date_utc, &data_path, compression);
868 }
869
870 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
871 let identifiers = Some(vec![instrument_id.to_string()]);
872
873 let quotes_out = catalog
874 .quote_ticks(identifiers.clone(), None, None)
875 .unwrap();
876 let greeks_out = catalog.option_greeks(identifiers, None, None).unwrap();
877
878 assert_eq!(greeks_out, vec![greeks_1, greeks_2]);
879 assert_eq!(greeks_out[0].instrument_id, instrument_id);
880 assert_eq!(greeks_out[0].mark_iv, Some(0.565));
881 assert_eq!(greeks_out[0].underlying_price, Some(63_500.0));
882 assert!(greeks_out[0].ts_init < greeks_out[1].ts_init);
883
884 assert_eq!(quotes_out, vec![quote_1, quote_2]);
885 assert_eq!(quotes_out[0].instrument_id, instrument_id);
886 assert!(quotes_out[0].ts_init < quotes_out[1].ts_init);
887 }
888
889 #[rstest]
890 fn test_trades_replay_catalog_round_trip() {
891 let instrument_id = InstrumentId::from("XBTUSD.BITMEX");
892 let compression = ParquetCompression::Zstd.as_parquet_compression();
893 let info = Arc::new(TardisInstrumentMiniInfo::new(
894 instrument_id,
895 None,
896 TardisExchange::Bitmex,
897 1,
898 0,
899 ));
900
901 let trade_msg: TradeMsg = serde_json::from_str(&load_test_json("trade.json")).unwrap();
902 let Some(Data::Trade(trade_1)) = parse_tardis_ws_message(
903 WsMessage::Trade(trade_msg),
904 &info,
905 &BookSnapshotOutput::Deltas,
906 ) else {
907 panic!("Expected trade message to route to Data::Trade");
908 };
909
910 let mut trade_2 = trade_1;
911 trade_2.ts_event = trade_1.ts_event + 1_000_000_000;
912 trade_2.ts_init = trade_1.ts_init + 1_000_000_000;
913
914 let temp_dir = tempfile::tempdir().unwrap();
915 let data_path = temp_dir.path().join("data");
916
917 let mut trades_map: AHashMap<InstrumentId, Vec<TradeTick>> = AHashMap::new();
918 let mut trades_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
919
920 for trade in [trade_1, trade_2] {
921 handle_trade_msg(
922 trade,
923 &mut trades_map,
924 &mut trades_cursors,
925 &data_path,
926 compression,
927 );
928 }
929
930 for (id, trades) in &trades_map {
931 let cursor = trades_cursors.get(id).expect("Expected cursor");
932 batch_and_write_trades(trades, id, cursor.date_utc, &data_path, compression);
933 }
934
935 assert!(data_path.join(TradeTick::path_prefix()).exists());
937 assert!(!data_path.join("trade_tick").exists());
938
939 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
940 let trades_out = catalog
941 .trade_ticks(Some(vec![instrument_id.to_string()]), None, None)
942 .unwrap();
943
944 assert_eq!(trades_out, vec![trade_1, trade_2]);
945 assert_eq!(trades_out[0].instrument_id, instrument_id);
946 assert!(trades_out[0].ts_init < trades_out[1].ts_init);
947 }
948
949 #[rstest]
950 fn test_bars_replay_catalog_round_trip() {
951 use nautilus_model::types::{Price, Quantity};
952
953 let compression = ParquetCompression::Zstd.as_parquet_compression();
954 let bar_type = BarType::from("BTCUSDT-PERP.BINANCE-1-MINUTE-LAST-EXTERNAL");
955
956 let ts_1 = UnixNanos::from(utc_nanos(2024, 1, 1, 0, 0, 0, 0));
957 let ts_2 = ts_1 + 1_000_000_000;
958
959 let bar_1 = Bar::new(
960 bar_type,
961 Price::from("100.00"),
962 Price::from("110.00"),
963 Price::from("90.00"),
964 Price::from("105.00"),
965 Quantity::from("1000"),
966 ts_1,
967 ts_1,
968 );
969 let bar_2 = Bar::new(
970 bar_type,
971 Price::from("105.00"),
972 Price::from("115.00"),
973 Price::from("95.00"),
974 Price::from("110.00"),
975 Quantity::from("1000"),
976 ts_2,
977 ts_2,
978 );
979
980 let temp_dir = tempfile::tempdir().unwrap();
981 let data_path = temp_dir.path().join("data");
982
983 let mut bars_map: AHashMap<BarType, Vec<Bar>> = AHashMap::new();
984 let mut bars_cursors: AHashMap<BarType, DateCursor> = AHashMap::new();
985
986 for bar in [bar_1, bar_2] {
987 handle_bar_msg(
988 bar,
989 &mut bars_map,
990 &mut bars_cursors,
991 &data_path,
992 compression,
993 );
994 }
995
996 for (bar_type, bars) in &bars_map {
997 let cursor = bars_cursors.get(bar_type).expect("Expected cursor");
998 batch_and_write_bars(bars, bar_type, cursor.date_utc, &data_path, compression);
999 }
1000
1001 assert!(data_path.join(Bar::path_prefix()).exists());
1004 assert!(!data_path.join("bar").exists());
1005
1006 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
1007 let bars_out = catalog.bars(None, None, None).unwrap();
1008
1009 assert_eq!(bars_out, vec![bar_1, bar_2]);
1010 assert_eq!(bars_out[0].bar_type, bar_type);
1011 assert!(bars_out[0].ts_init < bars_out[1].ts_init);
1012 }
1013}