1use std::{
17 fs,
18 path::{Path, PathBuf},
19};
20
21use ahash::{AHashMap, AHashSet};
22use anyhow::Context;
23use arrow::record_batch::RecordBatch;
24use chrono::{DateTime, Duration, NaiveDate};
25use futures_util::{StreamExt, pin_mut};
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_API,
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: NaiveDate,
50 end_ns: UnixNanos,
52}
53
54impl DateCursor {
55 fn new(current_ns: UnixNanos) -> Self {
57 let current_utc = DateTime::from_timestamp_nanos(current_ns.as_i64());
58 let date_utc = current_utc.date_naive();
59
60 let end_utc =
62 date_utc.and_hms_opt(23, 59, 59).unwrap() + Duration::nanoseconds(999_999_999);
63 let end_ns = UnixNanos::from(end_utc.and_utc().timestamp_nanos_opt().unwrap() as u64);
64
65 Self { date_utc, end_ns }
66 }
67}
68
69pub async fn run_tardis_machine_replay_from_config(config_filepath: &Path) -> anyhow::Result<()> {
80 log::debug!("Starting replay");
81 log::debug!("Config filepath: {}", config_filepath.display());
82
83 let config_data = fs::read_to_string(config_filepath)
85 .with_context(|| format!("Failed to read config file: {}", config_filepath.display()))?;
86 let config: TardisReplayConfig = serde_json::from_str(&config_data)
87 .context("failed to parse config JSON into TardisReplayConfig")?;
88
89 let path = config
90 .output_path
91 .as_deref()
92 .map(Path::new)
93 .map(Path::to_path_buf)
94 .or_else(|| {
95 std::env::var("NAUTILUS_PATH")
96 .ok()
97 .map(|env_path| PathBuf::from(env_path).join("catalog").join("data"))
98 })
99 .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));
100
101 log::debug!("Output path: {}", path.display());
102
103 let normalize_symbols = config.normalize_symbols.unwrap_or(true);
104 log::debug!("normalize_symbols={normalize_symbols}");
105
106 let book_snapshot_output = config
107 .book_snapshot_output
108 .clone()
109 .unwrap_or(BookSnapshotOutput::Deltas);
110 log::debug!("book_snapshot_output={book_snapshot_output:?}");
111
112 let extract_bbo_as_quotes = config.extract_bbo_as_quotes.unwrap_or(false);
113 log::debug!("extract_bbo_as_quotes={extract_bbo_as_quotes}");
114
115 let compression = config
116 .compression
117 .clone()
118 .unwrap_or(ParquetCompression::Zstd);
119 log::debug!("compression={compression:?}");
120 let compression = compression.as_parquet_compression();
121
122 let http_client = TardisHttpClient::new(
123 None,
124 None,
125 None,
126 normalize_symbols,
127 config.proxy_url.clone(),
128 )?;
129 let mut machine_client = TardisMachineClient::new(
130 config.tardis_ws_url.as_deref(),
131 normalize_symbols,
132 book_snapshot_output,
133 )?;
134 machine_client.extract_bbo_as_quotes = extract_bbo_as_quotes;
135
136 let exchanges: AHashSet<_> = config.options.iter().map(|opt| opt.exchange).collect();
137 let (instrument_map, _instruments) = http_client
138 .bootstrap_instruments(&exchanges)
139 .await
140 .context("failed to bootstrap instruments")?;
141
142 for (_, info) in &instrument_map {
143 machine_client.add_instrument_info((**info).clone());
144 }
145
146 log::debug!("Starting tardis-machine stream");
147 let stream = machine_client.replay(config.options).await?;
148 pin_mut!(stream);
149
150 let mut deltas_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
152 let mut depths_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
153 let mut quotes_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
154 let mut trades_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
155 let mut bars_cursors: AHashMap<BarType, DateCursor> = AHashMap::new();
156 let mut greeks_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
157
158 let mut deltas_map: AHashMap<InstrumentId, Vec<OrderBookDelta>> = AHashMap::new();
160 let mut depths_map: AHashMap<InstrumentId, Vec<OrderBookDepth10>> = AHashMap::new();
161 let mut quotes_map: AHashMap<InstrumentId, Vec<QuoteTick>> = AHashMap::new();
162 let mut trades_map: AHashMap<InstrumentId, Vec<TradeTick>> = AHashMap::new();
163 let mut bars_map: AHashMap<BarType, Vec<Bar>> = AHashMap::new();
164 let mut greeks_map: AHashMap<InstrumentId, Vec<OptionGreeks>> = AHashMap::new();
165
166 let mut msg_count = 0;
167
168 while let Some(result) = stream.next().await {
169 match result {
170 Ok(msg) => {
171 match msg {
172 Data::Deltas(msg) => {
173 handle_deltas_msg(
174 &msg,
175 &mut deltas_map,
176 &mut deltas_cursors,
177 &path,
178 compression,
179 );
180 }
181 Data::Depth10(msg) => {
182 handle_depth10_msg(
183 *msg,
184 &mut depths_map,
185 &mut depths_cursors,
186 &path,
187 compression,
188 );
189 }
190 Data::Quote(msg) => {
191 handle_quote_msg(
192 msg,
193 &mut quotes_map,
194 &mut quotes_cursors,
195 &path,
196 compression,
197 );
198 }
199 Data::Trade(msg) => {
200 handle_trade_msg(
201 msg,
202 &mut trades_map,
203 &mut trades_cursors,
204 &path,
205 compression,
206 );
207 }
208 Data::Bar(msg) => {
209 handle_bar_msg(msg, &mut bars_map, &mut bars_cursors, &path, compression);
210 }
211 Data::Delta(delta) => {
212 log::warn!(
213 "Skipping individual delta message for {} (use Deltas batch instead)",
214 delta.instrument_id
215 );
216 }
217 Data::OptionGreeks(msg) => {
218 handle_option_greeks_msg(
219 msg,
220 &mut greeks_map,
221 &mut greeks_cursors,
222 &path,
223 compression,
224 );
225 }
226 Data::MarkPriceUpdate(_)
227 | Data::IndexPriceUpdate(_)
228 | Data::FundingRateUpdate(_)
229 | Data::InstrumentStatus(_)
230 | Data::InstrumentClose(_)
231 | Data::Custom(_) => {
232 log::debug!(
233 "Skipping unsupported data type for instrument {}",
234 msg.instrument_id()
235 );
236 }
237 #[allow(unreachable_patterns)]
238 _ => {
239 log::debug!("Skipping unsupported data type");
240 }
241 }
242
243 msg_count += 1;
244 if msg_count % 100_000 == 0 {
245 log::debug!("Processed {} messages", msg_count.separate_with_commas());
246 }
247 }
248 Err(e) => {
249 log::error!("Stream error: {e:?}");
250 break;
251 }
252 }
253 }
254
255 for (instrument_id, deltas) in &deltas_map {
258 let cursor = deltas_cursors.get(instrument_id).expect("Expected cursor");
259 batch_and_write_deltas(deltas, instrument_id, cursor.date_utc, &path, compression);
260 }
261
262 for (instrument_id, depths) in &depths_map {
263 let cursor = depths_cursors.get(instrument_id).expect("Expected cursor");
264 batch_and_write_depths(depths, instrument_id, cursor.date_utc, &path, compression);
265 }
266
267 for (instrument_id, quotes) in "es_map {
268 let cursor = quotes_cursors.get(instrument_id).expect("Expected cursor");
269 batch_and_write_quotes(quotes, instrument_id, cursor.date_utc, &path, compression);
270 }
271
272 for (instrument_id, trades) in &trades_map {
273 let cursor = trades_cursors.get(instrument_id).expect("Expected cursor");
274 batch_and_write_trades(trades, instrument_id, cursor.date_utc, &path, compression);
275 }
276
277 for (bar_type, bars) in &bars_map {
278 let cursor = bars_cursors.get(bar_type).expect("Expected cursor");
279 batch_and_write_bars(bars, bar_type, cursor.date_utc, &path, compression);
280 }
281
282 for (instrument_id, greeks) in &greeks_map {
283 let cursor = greeks_cursors.get(instrument_id).expect("Expected cursor");
284 batch_and_write_greeks(greeks, instrument_id, cursor.date_utc, &path, compression);
285 }
286
287 log::debug!(
288 "Replay completed after {} messages",
289 msg_count.separate_with_commas()
290 );
291 Ok(())
292}
293
294fn handle_deltas_msg(
295 deltas: &OrderBookDeltas_API,
296 map: &mut AHashMap<InstrumentId, Vec<OrderBookDelta>>,
297 cursors: &mut AHashMap<InstrumentId, DateCursor>,
298 path: &Path,
299 compression: Compression,
300) {
301 let cursor = cursors
302 .entry(deltas.instrument_id)
303 .or_insert_with(|| DateCursor::new(deltas.ts_init));
304
305 if deltas.ts_init > cursor.end_ns {
306 if let Some(deltas_vec) = map.remove(&deltas.instrument_id) {
307 batch_and_write_deltas(
308 &deltas_vec,
309 &deltas.instrument_id,
310 cursor.date_utc,
311 path,
312 compression,
313 );
314 }
315 *cursor = DateCursor::new(deltas.ts_init);
317 }
318
319 map.entry(deltas.instrument_id)
320 .or_insert_with(|| Vec::with_capacity(100_000))
321 .extend(&*deltas.deltas);
322}
323
324fn handle_depth10_msg(
325 depth10: OrderBookDepth10,
326 map: &mut AHashMap<InstrumentId, Vec<OrderBookDepth10>>,
327 cursors: &mut AHashMap<InstrumentId, DateCursor>,
328 path: &Path,
329 compression: Compression,
330) {
331 let cursor = cursors
332 .entry(depth10.instrument_id)
333 .or_insert_with(|| DateCursor::new(depth10.ts_init));
334
335 if depth10.ts_init > cursor.end_ns {
336 if let Some(depths_vec) = map.remove(&depth10.instrument_id) {
337 batch_and_write_depths(
338 &depths_vec,
339 &depth10.instrument_id,
340 cursor.date_utc,
341 path,
342 compression,
343 );
344 }
345 *cursor = DateCursor::new(depth10.ts_init);
347 }
348
349 map.entry(depth10.instrument_id)
350 .or_insert_with(|| Vec::with_capacity(100_000))
351 .push(depth10);
352}
353
354fn handle_quote_msg(
355 quote: QuoteTick,
356 map: &mut AHashMap<InstrumentId, Vec<QuoteTick>>,
357 cursors: &mut AHashMap<InstrumentId, DateCursor>,
358 path: &Path,
359 compression: Compression,
360) {
361 let cursor = cursors
362 .entry(quote.instrument_id)
363 .or_insert_with(|| DateCursor::new(quote.ts_init));
364
365 if quote.ts_init > cursor.end_ns {
366 if let Some(quotes_vec) = map.remove("e.instrument_id) {
367 batch_and_write_quotes(
368 "es_vec,
369 "e.instrument_id,
370 cursor.date_utc,
371 path,
372 compression,
373 );
374 }
375 *cursor = DateCursor::new(quote.ts_init);
377 }
378
379 map.entry(quote.instrument_id)
380 .or_insert_with(|| Vec::with_capacity(100_000))
381 .push(quote);
382}
383
384fn handle_trade_msg(
385 trade: TradeTick,
386 map: &mut AHashMap<InstrumentId, Vec<TradeTick>>,
387 cursors: &mut AHashMap<InstrumentId, DateCursor>,
388 path: &Path,
389 compression: Compression,
390) {
391 let cursor = cursors
392 .entry(trade.instrument_id)
393 .or_insert_with(|| DateCursor::new(trade.ts_init));
394
395 if trade.ts_init > cursor.end_ns {
396 if let Some(trades_vec) = map.remove(&trade.instrument_id) {
397 batch_and_write_trades(
398 &trades_vec,
399 &trade.instrument_id,
400 cursor.date_utc,
401 path,
402 compression,
403 );
404 }
405 *cursor = DateCursor::new(trade.ts_init);
407 }
408
409 map.entry(trade.instrument_id)
410 .or_insert_with(|| Vec::with_capacity(100_000))
411 .push(trade);
412}
413
414fn handle_bar_msg(
415 bar: Bar,
416 map: &mut AHashMap<BarType, Vec<Bar>>,
417 cursors: &mut AHashMap<BarType, DateCursor>,
418 path: &Path,
419 compression: Compression,
420) {
421 let cursor = cursors
422 .entry(bar.bar_type)
423 .or_insert_with(|| DateCursor::new(bar.ts_init));
424
425 if bar.ts_init > cursor.end_ns {
426 if let Some(bars_vec) = map.remove(&bar.bar_type) {
427 batch_and_write_bars(&bars_vec, &bar.bar_type, cursor.date_utc, path, compression);
428 }
429 *cursor = DateCursor::new(bar.ts_init);
431 }
432
433 map.entry(bar.bar_type)
434 .or_insert_with(|| Vec::with_capacity(100_000))
435 .push(bar);
436}
437
438fn handle_option_greeks_msg(
439 greeks: OptionGreeks,
440 map: &mut AHashMap<InstrumentId, Vec<OptionGreeks>>,
441 cursors: &mut AHashMap<InstrumentId, DateCursor>,
442 path: &Path,
443 compression: Compression,
444) {
445 let cursor = cursors
446 .entry(greeks.instrument_id)
447 .or_insert_with(|| DateCursor::new(greeks.ts_init));
448
449 if greeks.ts_init > cursor.end_ns {
450 if let Some(greeks_vec) = map.remove(&greeks.instrument_id) {
451 batch_and_write_greeks(
452 &greeks_vec,
453 &greeks.instrument_id,
454 cursor.date_utc,
455 path,
456 compression,
457 );
458 }
459 *cursor = DateCursor::new(greeks.ts_init);
461 }
462
463 map.entry(greeks.instrument_id)
464 .or_insert_with(|| Vec::with_capacity(100_000))
465 .push(greeks);
466}
467
468fn batch_and_write_deltas(
469 deltas: &[OrderBookDelta],
470 instrument_id: &InstrumentId,
471 date: NaiveDate,
472 path: &Path,
473 compression: Compression,
474) {
475 match book_deltas_to_arrow_record_batch_bytes(deltas) {
476 Ok(batch) => write_batch(
477 &batch,
478 "order_book_deltas",
479 instrument_id,
480 date,
481 path,
482 compression,
483 ),
484 Err(e) => {
485 log::error!("Error converting OrderBookDeltas to Arrow: {e:?}");
486 }
487 }
488}
489
490fn batch_and_write_depths(
491 depths: &[OrderBookDepth10],
492 instrument_id: &InstrumentId,
493 date: NaiveDate,
494 path: &Path,
495 compression: Compression,
496) {
497 match book_depth10_to_arrow_record_batch_bytes(depths) {
498 Ok(batch) => write_batch(
499 &batch,
500 "order_book_depths",
501 instrument_id,
502 date,
503 path,
504 compression,
505 ),
506 Err(e) => {
507 log::error!("Error converting OrderBookDepth10 to Arrow: {e:?}");
508 }
509 }
510}
511
512fn batch_and_write_quotes(
513 quotes: &[QuoteTick],
514 instrument_id: &InstrumentId,
515 date: NaiveDate,
516 path: &Path,
517 compression: Compression,
518) {
519 match quotes_to_arrow_record_batch_bytes(quotes) {
520 Ok(batch) => write_batch(
521 &batch,
522 QuoteTick::path_prefix(),
523 instrument_id,
524 date,
525 path,
526 compression,
527 ),
528 Err(e) => {
529 log::error!("Error converting QuoteTick to Arrow: {e:?}");
530 }
531 }
532}
533
534fn batch_and_write_trades(
535 trades: &[TradeTick],
536 instrument_id: &InstrumentId,
537 date: NaiveDate,
538 path: &Path,
539 compression: Compression,
540) {
541 match trades_to_arrow_record_batch_bytes(trades) {
542 Ok(batch) => write_batch(&batch, "trade_tick", instrument_id, date, path, compression),
543 Err(e) => {
544 log::error!("Error converting TradeTick to Arrow: {e:?}");
545 }
546 }
547}
548
549fn batch_and_write_bars(
550 bars: &[Bar],
551 bar_type: &BarType,
552 date: NaiveDate,
553 path: &Path,
554 compression: Compression,
555) {
556 let batch = match bars_to_arrow_record_batch_bytes(bars) {
557 Ok(batch) => batch,
558 Err(e) => {
559 log::error!("Error converting Bar to Arrow: {e:?}");
560 return;
561 }
562 };
563
564 let filepath = path.join(parquet_filepath_bars(bar_type, date));
565 if let Err(e) = write_parquet_local(&batch, &filepath, compression) {
566 log::error!("Error writing {}: {e}", filepath.display());
567 } else {
568 log::debug!("File written: {}", filepath.display());
569 }
570}
571
572fn batch_and_write_greeks(
573 greeks: &[OptionGreeks],
574 instrument_id: &InstrumentId,
575 date: NaiveDate,
576 path: &Path,
577 compression: Compression,
578) {
579 match option_greeks_to_arrow_record_batch_bytes(greeks) {
580 Ok(batch) => write_batch(
581 &batch,
582 OptionGreeks::path_prefix(),
583 instrument_id,
584 date,
585 path,
586 compression,
587 ),
588 Err(e) => {
589 log::error!("Error converting OptionGreeks to Arrow: {e:?}");
590 }
591 }
592}
593
594fn assert_post_epoch(date: NaiveDate) {
601 let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("UNIX epoch must exist");
602 assert!(
603 date >= epoch,
604 "Tardis replay filenames require dates on or after 1970-01-01; received {date}"
605 );
606}
607
608fn iso_timestamp_to_file_timestamp(iso_timestamp: &str) -> String {
613 iso_timestamp.replace([':', '.'], "-")
614}
615
616fn timestamps_to_filename(timestamp_1: UnixNanos, timestamp_2: UnixNanos) -> String {
621 let datetime_1 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_1));
622 let datetime_2 = iso_timestamp_to_file_timestamp(&unix_nanos_to_iso8601(timestamp_2));
623
624 format!("{datetime_1}_{datetime_2}.parquet")
625}
626
627fn parquet_filepath(typename: &str, instrument_id: &InstrumentId, date: NaiveDate) -> PathBuf {
628 assert_post_epoch(date);
629
630 let instrument_id_str = instrument_id.to_string().replace('/', "");
631
632 let start_utc = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
633 let end_utc = date.and_hms_opt(23, 59, 59).unwrap() + Duration::nanoseconds(999_999_999);
634
635 let start_nanos = start_utc
636 .timestamp_nanos_opt()
637 .expect("valid nanosecond timestamp");
638 let end_nanos = (end_utc.and_utc())
639 .timestamp_nanos_opt()
640 .expect("valid nanosecond timestamp");
641
642 let filename = timestamps_to_filename(
643 UnixNanos::from(start_nanos as u64),
644 UnixNanos::from(end_nanos as u64),
645 );
646
647 PathBuf::new()
648 .join(typename)
649 .join(instrument_id_str)
650 .join(filename)
651}
652
653fn parquet_filepath_bars(bar_type: &BarType, date: NaiveDate) -> PathBuf {
654 assert_post_epoch(date);
655
656 let bar_type_str = bar_type.to_string().replace('/', "");
657
658 let start_utc = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
660 let end_utc = date.and_hms_opt(23, 59, 59).unwrap() + Duration::nanoseconds(999_999_999);
661
662 let start_nanos = start_utc
663 .timestamp_nanos_opt()
664 .expect("valid nanosecond timestamp");
665 let end_nanos = (end_utc.and_utc())
666 .timestamp_nanos_opt()
667 .expect("valid nanosecond timestamp");
668
669 let filename = timestamps_to_filename(
670 UnixNanos::from(start_nanos as u64),
671 UnixNanos::from(end_nanos as u64),
672 );
673
674 PathBuf::new().join("bar").join(bar_type_str).join(filename)
675}
676
677fn write_batch(
678 batch: &RecordBatch,
679 typename: &str,
680 instrument_id: &InstrumentId,
681 date: NaiveDate,
682 path: &Path,
683 compression: Compression,
684) {
685 let filepath = path.join(parquet_filepath(typename, instrument_id, date));
686 if let Err(e) = write_parquet_local(batch, &filepath, compression) {
687 log::error!("Error writing {}: {e}", filepath.display());
688 } else {
689 log::debug!("File written: {}", filepath.display());
690 }
691}
692
693fn write_parquet_local(
694 batch: &RecordBatch,
695 file_path: &Path,
696 compression: Compression,
697) -> anyhow::Result<()> {
698 if let Some(parent) = file_path.parent() {
699 std::fs::create_dir_all(parent)?;
700 }
701
702 let file = std::fs::File::create(file_path)?;
703 let props = WriterProperties::builder()
704 .set_compression(compression)
705 .build();
706
707 let mut writer = ArrowWriter::try_new(file, batch.schema(), Some(props))?;
708 writer.write(batch)?;
709 writer.close()?;
710 Ok(())
711}
712
713#[cfg(test)]
714mod tests {
715 use std::sync::Arc;
716
717 use chrono::{TimeZone, Utc};
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, WsMessage},
727 parse::parse_tardis_ws_message,
728 types::TardisInstrumentMiniInfo,
729 },
730 };
731
732 #[rstest]
733 #[case(
734 Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap().timestamp_nanos_opt().unwrap() as u64,
736 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
737 Utc.with_ymd_and_hms(2024, 1, 1, 23, 59, 59).unwrap().timestamp_nanos_opt().unwrap() as u64 + 999_999_999
738)]
739 #[case(
740 Utc.with_ymd_and_hms(2024, 1, 1, 12, 0, 0).unwrap().timestamp_nanos_opt().unwrap() as u64,
742 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
743 Utc.with_ymd_and_hms(2024, 1, 1, 23, 59, 59).unwrap().timestamp_nanos_opt().unwrap() as u64 + 999_999_999
744)]
745 #[case(
746 Utc.with_ymd_and_hms(2024, 1, 1, 23, 59, 59).unwrap().timestamp_nanos_opt().unwrap() as u64 + 999_999_999,
748 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
749 Utc.with_ymd_and_hms(2024, 1, 1, 23, 59, 59).unwrap().timestamp_nanos_opt().unwrap() as u64 + 999_999_999
750)]
751 #[case(
752 Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap().timestamp_nanos_opt().unwrap() as u64,
754 NaiveDate::from_ymd_opt(2024, 1, 2).unwrap(),
755 Utc.with_ymd_and_hms(2024, 1, 2, 23, 59, 59).unwrap().timestamp_nanos_opt().unwrap() as u64 + 999_999_999
756)]
757 fn test_date_cursor(
758 #[case] timestamp: u64,
759 #[case] expected_date: NaiveDate,
760 #[case] expected_end_ns: u64,
761 ) {
762 let unix_nanos = UnixNanos::from(timestamp);
763 let cursor = DateCursor::new(unix_nanos);
764
765 assert_eq!(cursor.date_utc, expected_date);
766 assert_eq!(cursor.end_ns, UnixNanos::from(expected_end_ns));
767 }
768
769 #[rstest]
770 fn test_option_greeks_replay_catalog_round_trip() {
771 let instrument_id = InstrumentId::from("BTC-28JUN24-70000-C.DERIBIT");
772 let compression = ParquetCompression::Zstd.as_parquet_compression();
773 let info = Arc::new(TardisInstrumentMiniInfo::new(
774 instrument_id,
775 None,
776 TardisExchange::Deribit,
777 4,
778 1,
779 ));
780
781 let option_summary: OptionSummaryMsg =
782 serde_json::from_str(&load_test_json("option_summary.json")).unwrap();
783 let Some(Data::OptionGreeks(greeks_1)) = parse_tardis_ws_message(
784 WsMessage::OptionSummary(option_summary),
785 &info,
786 &BookSnapshotOutput::Deltas,
787 ) else {
788 panic!("Expected option_summary to route to Data::OptionGreeks");
789 };
790
791 let mut greeks_2 = greeks_1;
792 greeks_2.ts_event = greeks_1.ts_event + 1_000_000_000;
793 greeks_2.ts_init = greeks_1.ts_init + 1_000_000_000;
794 greeks_2.greeks.delta = 0.26;
795
796 let option_quote: BookSnapshotMsg =
797 serde_json::from_str(&load_test_json("option_book_snapshot.json")).unwrap();
798 let Some(Data::Quote(quote_1)) = parse_tardis_ws_message(
799 WsMessage::BookSnapshot(option_quote),
800 &info,
801 &BookSnapshotOutput::Deltas,
802 ) else {
803 panic!("Expected depth-1 option book snapshot to route to Data::Quote");
804 };
805
806 let mut quote_2 = quote_1;
807 quote_2.ts_event = quote_1.ts_event + 1_000_000_000;
808 quote_2.ts_init = quote_1.ts_init + 1_000_000_000;
809
810 let temp_dir = tempfile::tempdir().unwrap();
811 let data_path = temp_dir.path().join("data");
812
813 let mut quotes_map: AHashMap<InstrumentId, Vec<QuoteTick>> = AHashMap::new();
814 let mut quotes_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
815 let mut greeks_map: AHashMap<InstrumentId, Vec<OptionGreeks>> = AHashMap::new();
816 let mut greeks_cursors: AHashMap<InstrumentId, DateCursor> = AHashMap::new();
817
818 for quote in [quote_1, quote_2] {
819 handle_quote_msg(
820 quote,
821 &mut quotes_map,
822 &mut quotes_cursors,
823 &data_path,
824 compression,
825 );
826 }
827
828 for greeks in [greeks_1, greeks_2] {
829 handle_option_greeks_msg(
830 greeks,
831 &mut greeks_map,
832 &mut greeks_cursors,
833 &data_path,
834 compression,
835 );
836 }
837
838 for (id, quotes) in "es_map {
839 let cursor = quotes_cursors.get(id).expect("Expected cursor");
840 batch_and_write_quotes(quotes, id, cursor.date_utc, &data_path, compression);
841 }
842
843 for (id, greeks) in &greeks_map {
844 let cursor = greeks_cursors.get(id).expect("Expected cursor");
845 batch_and_write_greeks(greeks, id, cursor.date_utc, &data_path, compression);
846 }
847
848 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
849 let identifiers = Some(vec![instrument_id.to_string()]);
850
851 let quotes_out = catalog
852 .quote_ticks(identifiers.clone(), None, None)
853 .unwrap();
854 let greeks_out = catalog.option_greeks(identifiers, None, None).unwrap();
855
856 assert_eq!(greeks_out, vec![greeks_1, greeks_2]);
857 assert_eq!(greeks_out[0].instrument_id, instrument_id);
858 assert_eq!(greeks_out[0].mark_iv, Some(0.565));
859 assert_eq!(greeks_out[0].underlying_price, Some(63_500.0));
860 assert!(greeks_out[0].ts_init < greeks_out[1].ts_init);
861
862 assert_eq!(quotes_out, vec![quote_1, quote_2]);
863 assert_eq!(quotes_out[0].instrument_id, instrument_id);
864 assert!(quotes_out[0].ts_init < quotes_out[1].ts_init);
865 }
866}