Skip to main content

nautilus_tardis/
replay.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16use 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    /// Cursor date UTC.
49    date_utc: NaiveDate,
50    /// Cursor end timestamp UNIX nanoseconds.
51    end_ns: UnixNanos,
52}
53
54impl DateCursor {
55    /// Creates a new [`DateCursor`] instance.
56    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        // Calculate end of the current UTC day
61        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
69/// Runs the Tardis Machine replay from a JSON configuration file.
70///
71/// # Errors
72///
73/// Returns an error if reading or parsing the config file fails,
74/// or if any downstream replay operation fails.
75///
76/// # Panics
77///
78/// Panics if unable to determine the output path (current directory fallback fails).
79pub 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    // Load and parse the replay configuration
84    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    // Initialize date cursors
151    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    // Initialize date collection maps
159    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    // Iterate through every remaining type and instrument sequentially
256
257    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 &quotes_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        // Update cursor
316        *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        // Update cursor
346        *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(&quote.instrument_id) {
367            batch_and_write_quotes(
368                &quotes_vec,
369                &quote.instrument_id,
370                cursor.date_utc,
371                path,
372                compression,
373            );
374        }
375        // Update cursor
376        *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        // Update cursor
406        *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        // Update cursor
430        *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        // Update cursor
460        *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
594/// Asserts that the given date is on or after the UNIX epoch (1970-01-01).
595///
596/// # Panics
597///
598/// Panics if the date is before 1970-01-01, as pre-epoch dates cannot be
599/// reliably represented as UnixNanos without overflow issues.
600fn 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
608/// Converts an ISO 8601 timestamp to a filesystem-safe format.
609///
610/// This function replaces colons and dots with hyphens to make the timestamp
611/// safe for use in filenames across different filesystems.
612fn iso_timestamp_to_file_timestamp(iso_timestamp: &str) -> String {
613    iso_timestamp.replace([':', '.'], "-")
614}
615
616/// Converts timestamps to a filename using ISO 8601 format.
617///
618/// This function converts two Unix nanosecond timestamps to a filename that uses
619/// ISO 8601 format with filesystem-safe characters, matching the catalog convention.
620fn 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    // Calculate start and end timestamps for the day (UTC)
659    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    // Start of day: 2024-01-01 00:00:00 UTC
735    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    // Midday: 2024-01-01 12:00:00 UTC
741    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    // End of day: 2024-01-01 23:59:59.999999999 UTC
747    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    // Start of new day: 2024-01-02 00:00:00 UTC
753    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 &quotes_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}