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 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    /// Cursor date UTC.
49    date_utc: Date,
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 = current_ns.to_datetime_utc();
58        let date_utc = Offset::UTC.to_datetime(current_utc).date();
59
60        // Calculate end of the current UTC day
61        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
74/// Runs the Tardis Machine replay from a JSON configuration file.
75///
76/// # Errors
77///
78/// Returns an error if reading or parsing the config file fails,
79/// or if any downstream replay operation fails.
80///
81/// # Panics
82///
83/// Panics if unable to determine the output path (current directory fallback fails).
84pub 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    // Load and parse the replay configuration
89    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    // Initialize date cursors
156    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    // Initialize date collection maps
164    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    // Iterate through every remaining type and instrument sequentially
261
262    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 &quotes_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        // Update cursor
321        *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        // Update cursor
351        *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(&quote.instrument_id) {
372            batch_and_write_quotes(
373                &quotes_vec,
374                &quote.instrument_id,
375                cursor.date_utc,
376                path,
377                compression,
378            );
379        }
380        // Update cursor
381        *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        // Update cursor
411        *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        // Update cursor
435        *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        // Update cursor
465        *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
606/// Asserts that the given date is on or after the UNIX epoch (1970-01-01).
607///
608/// # Panics
609///
610/// Panics if the date is before 1970-01-01, as pre-epoch dates cannot be
611/// reliably represented as UnixNanos without overflow issues.
612fn 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
620/// Converts an ISO 8601 timestamp to a filesystem-safe format.
621///
622/// This function replaces colons and dots with hyphens to make the timestamp
623/// safe for use in filenames across different filesystems.
624fn iso_timestamp_to_file_timestamp(iso_timestamp: &str) -> String {
625    iso_timestamp.replace([':', '.'], "-")
626}
627
628/// Converts timestamps to a filename using ISO 8601 format.
629///
630/// This function converts two Unix nanosecond timestamps to a filename that uses
631/// ISO 8601 format with filesystem-safe characters, matching the catalog convention.
632fn 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    // Calculate start and end timestamps for the day (UTC)
664    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    // Start of day: 2024-01-01 00:00:00 UTC
757    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    // Midday: 2024-01-01 12:00:00 UTC
763    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    // End of day: 2024-01-01 23:59:59.999999999 UTC
769    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    // Start of new day: 2024-01-02 00:00:00 UTC
775    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 &quotes_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        // Trades must be written under the catalog convention path ("trades")
936        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        // Bars must be written under the catalog convention path ("bars"), consistent
1002        // with the other data types so they are discoverable by `ParquetDataCatalog`.
1003        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}