Skip to main content

nautilus_testkit/
common.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::File,
18    path::{Path, PathBuf},
19    sync::OnceLock,
20};
21
22use nautilus_core::paths::get_test_data_path;
23use nautilus_model::{
24    data::OrderBookDelta,
25    instruments::{InstrumentAny, stubs::equity_aapl_itch},
26    types::fixed::PRECISION_BYTES,
27};
28use nautilus_serialization::arrow::{DecodeFromRecordBatch, normalize_legacy_fixed_columns};
29use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
30
31/// Returns the full path to the test data file at the specified relative `path` within the standard test data directory.
32///
33/// # Panics
34///
35/// Panics if the computed path cannot be represented as a valid UTF-8 string.
36#[must_use]
37pub fn get_test_data_file_path(path: &str) -> String {
38    get_test_data_path()
39        .join(path)
40        .to_str()
41        .unwrap()
42        .to_string()
43}
44
45/// Returns the full path to the Nautilus-specific legacy test data file given by `filename`.
46///
47/// Files are resolved under `legacy/64-bit` or `legacy/128-bit` for the active model build.
48///
49/// # Panics
50///
51/// Panics if the computed path cannot be represented as a valid UTF-8 string.
52#[must_use]
53pub fn get_nautilus_test_data_file_path(filename: &str) -> String {
54    let precision_directory = format!("{}-bit", PRECISION_BYTES * 8);
55    let path = get_test_data_path()
56        .join("nautilus")
57        .join("legacy")
58        .join(precision_directory);
59
60    path.join(filename).to_str().unwrap().to_string()
61}
62
63/// Returns the path to the checksums file for large test data files.
64#[must_use]
65pub fn get_test_data_large_checksums_filepath() -> PathBuf {
66    get_test_data_path().join("large").join("checksums.json")
67}
68
69/// Returns the path to a large test data file that is already present locally.
70///
71/// # Panics
72///
73/// Panics if the file is missing, with the command to prepare test data.
74#[must_use]
75pub fn ensure_test_data_exists(filename: &str) -> PathBuf {
76    let filepath = get_test_data_path().join("large").join(filename);
77    assert!(
78        filepath.is_file(),
79        "Missing test data file: {}. Run `cargo run --locked -p nautilus-testkit --bin prepare-test-data` before testing.",
80        filepath.display(),
81    );
82    filepath
83}
84
85/// Returns the path to the local NASDAQ ITCH AAPL deltas Parquet file.
86///
87/// # Panics
88///
89/// Panics if the file is missing, with the command to prepare test data.
90#[must_use]
91pub fn ensure_itch_aapl_deltas_parquet() -> PathBuf {
92    ensure_test_data_exists("itch_AAPL.XNAS_2019-01-30_deltas.parquet")
93}
94
95/// Returns the path to the local Tardis Deribit BTC-PERPETUAL deltas Parquet file.
96///
97/// # Panics
98///
99/// Panics if the file is missing, with the command to prepare test data.
100#[must_use]
101pub fn ensure_tardis_deribit_deltas_parquet() -> PathBuf {
102    ensure_test_data_exists("tardis_BTC-PERPETUAL.DERIBIT_2020-04-01_deltas.parquet")
103}
104
105/// Returns the path to the local HISTDATA EURUSD.SIM quotes Parquet file.
106///
107/// # Panics
108///
109/// Panics if the file is missing, with the command to prepare test data.
110#[must_use]
111pub fn ensure_histdata_eurusd_quotes_parquet() -> PathBuf {
112    ensure_test_data_exists("histdata_EURUSD.SIM_2020-01_quotes.parquet")
113}
114
115/// Returns the path to the local HISTDATA EURUSD.SIM instrument Parquet file.
116///
117/// # Panics
118///
119/// Panics if the file is missing, with the command to prepare test data.
120#[must_use]
121pub fn ensure_histdata_eurusd_instrument_parquet() -> PathBuf {
122    ensure_test_data_exists("histdata_EURUSD.SIM_2020-01_instrument.parquet")
123}
124
125/// Returns the path to the Tardis Deribit incremental book L2 test data.
126#[must_use]
127pub fn get_tardis_deribit_book_l2_path() -> PathBuf {
128    get_test_data_path()
129        .join("tardis")
130        .join("deribit_incremental_book_L2_BTC-PERPETUAL.csv")
131}
132
133/// Returns the path to the Tardis Binance Futures book snapshot (depth 5) test data.
134#[must_use]
135pub fn get_tardis_binance_snapshot5_path() -> PathBuf {
136    get_test_data_path()
137        .join("tardis")
138        .join("binance-futures_book_snapshot_5_BTCUSDT.csv")
139}
140
141/// Returns the path to the Tardis Binance Futures book snapshot (depth 25) test data.
142#[must_use]
143pub fn get_tardis_binance_snapshot25_path() -> PathBuf {
144    get_test_data_path()
145        .join("tardis")
146        .join("binance-futures_book_snapshot_25_BTCUSDT.csv")
147}
148
149/// Returns the path to the Tardis Huobi quotes test data.
150#[must_use]
151pub fn get_tardis_huobi_quotes_path() -> PathBuf {
152    get_test_data_path()
153        .join("tardis")
154        .join("huobi-dm-swap_quotes_BTC-USD.csv")
155}
156
157/// Returns the path to the Tardis Bitmex trades test data.
158#[must_use]
159pub fn get_tardis_bitmex_trades_path() -> PathBuf {
160    get_test_data_path()
161        .join("tardis")
162        .join("bitmex_trades_XBTUSD.csv")
163}
164
165/// Returns an AAPL equity instrument with ITCH-compatible precision
166/// (`price_precision=4`, `price_increment=0.0001`).
167#[must_use]
168pub fn itch_aapl_equity() -> InstrumentAny {
169    InstrumentAny::Equity(equity_aapl_itch())
170}
171
172/// Loads ITCH AAPL order book deltas from the parquet test dataset.
173///
174/// Requires prepared local test data. Pass `limit` to subsample.
175#[must_use]
176pub fn load_itch_aapl_deltas(limit: Option<usize>) -> Vec<OrderBookDelta> {
177    static PATH: OnceLock<PathBuf> = OnceLock::new();
178    let filepath = PATH.get_or_init(ensure_itch_aapl_deltas_parquet);
179    load_deltas_from_parquet(filepath, limit)
180}
181
182/// Loads Tardis Deribit BTC-PERPETUAL order book deltas from the parquet test dataset.
183///
184/// Requires prepared local test data. Pass `limit` to subsample.
185#[must_use]
186pub fn load_tardis_deribit_deltas(limit: Option<usize>) -> Vec<OrderBookDelta> {
187    static PATH: OnceLock<PathBuf> = OnceLock::new();
188    let filepath = PATH.get_or_init(ensure_tardis_deribit_deltas_parquet);
189    load_deltas_from_parquet(filepath, limit)
190}
191
192fn load_deltas_from_parquet(filepath: &Path, limit: Option<usize>) -> Vec<OrderBookDelta> {
193    let file = File::open(filepath).unwrap();
194    let mut builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
195    let metadata = builder.schema().metadata().clone();
196
197    if let Some(limit) = limit {
198        builder = builder.with_limit(limit);
199    }
200    let reader = builder.build().unwrap();
201
202    let mut deltas = Vec::new();
203
204    for batch_result in reader {
205        let batch = normalize_legacy_fixed_columns(&batch_result.unwrap()).unwrap();
206        let batch_deltas = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
207        deltas.extend(batch_deltas);
208    }
209    deltas
210}
211
212#[cfg(test)]
213mod tests {
214    use rstest::rstest;
215    use tempfile::TempDir;
216
217    use super::*;
218
219    #[rstest]
220    #[case::file("file")]
221    #[case::missing("missing")]
222    #[case::directory("directory")]
223    fn test_ensure_test_data_exists(#[case] state: &str) {
224        let directory = TempDir::new().unwrap();
225        let filepath = directory.path().join("fixture.parquet");
226        if state == "file" {
227            std::fs::write(&filepath, "local fixture").unwrap();
228        } else if state == "directory" {
229            std::fs::create_dir(&filepath).unwrap();
230        }
231
232        // The absolute path isolates this test without changing the shared test data root
233        let result =
234            std::panic::catch_unwind(|| ensure_test_data_exists(filepath.to_str().unwrap()));
235
236        if state == "file" {
237            assert_eq!(result.unwrap(), filepath);
238            assert_eq!(std::fs::read_to_string(&filepath).unwrap(), "local fixture");
239        } else {
240            let panic = result.unwrap_err().downcast::<String>().unwrap();
241            assert_eq!(
242                *panic,
243                format!(
244                    "Missing test data file: {}. Run `cargo run --locked -p nautilus-testkit --bin prepare-test-data` before testing.",
245                    filepath.display(),
246                ),
247            );
248            assert_eq!(filepath.exists(), state == "directory");
249        }
250    }
251}