1pub mod convert;
17pub mod load;
18pub mod stream;
19
20mod record;
21
22use std::{
23 ffi::OsStr,
24 fs::File,
25 io::{BufReader, Read, Seek, SeekFrom},
26 path::Path,
27 time::Duration,
28};
29
30use csv::{Reader, ReaderBuilder};
31use flate2::read::GzDecoder;
32pub use load::{
33 load_deltas, load_depth10_from_snapshot5, load_depth10_from_snapshot25, load_funding_rates,
34 load_options_chain, load_quotes, load_trades,
35};
36use nautilus_model::{
37 data::{
38 BookOrder, FundingRateUpdate, NULL_ORDER, OptionGreekValues, OptionGreeks, OrderBookDelta,
39 QuoteTick, TradeTick,
40 },
41 enums::{BookAction, GreeksConvention, OrderSide},
42 identifiers::{InstrumentId, TradeId},
43 types::{Price, Quantity},
44};
45use rust_decimal::Decimal;
46pub use stream::{
47 stream_deltas, stream_depth10_from_snapshot5, stream_depth10_from_snapshot25,
48 stream_funding_rates, stream_options_chain, stream_quotes, stream_trades,
49};
50
51use super::csv::record::{
52 TardisBookUpdateRecord, TardisDerivativeTickerRecord, TardisOptionsChainRecord,
53 TardisQuoteRecord, TardisTradeRecord,
54};
55use crate::common::parse::{
56 derive_trade_id, parse_aggressor_side, parse_book_action, parse_instrument_id,
57 parse_order_side, parse_price, parse_timestamp,
58};
59
60fn infer_precision(value: f64) -> u8 {
61 let mut buf = ryu::Buffer::new(); let s = buf.format(value);
63
64 match s.rsplit_once('.') {
65 Some((_, frac)) if frac != "0" => frac.len() as u8,
66 _ => 0,
67 }
68}
69
70fn create_csv_reader<P: AsRef<Path>>(
71 filepath: P,
72) -> anyhow::Result<Reader<Box<dyn std::io::Read>>> {
73 const MAX_RETRIES: u8 = 3;
74 const DELAY_MS: u64 = 100;
75 const BUFFER_SIZE: usize = 8 * 1024 * 1024; fn open_file_with_retry<P: AsRef<Path>>(
78 path: P,
79 max_retries: u8,
80 delay_ms: u64,
81 ) -> anyhow::Result<File> {
82 let path_ref = path.as_ref();
83 for attempt in 1..=max_retries {
84 match File::open(path_ref) {
85 Ok(file) => return Ok(file),
86 Err(e) => {
87 if attempt == max_retries {
88 anyhow::bail!(
89 "Failed to open file '{}' after {max_retries} attempts: {e}",
90 path_ref.display()
91 );
92 }
93 log::warn!(
94 "Attempt {attempt}/{max_retries} failed to open file '{}': {e}. Retrying after {delay_ms}ms...",
95 path_ref.display()
96 );
97 std::thread::sleep(Duration::from_millis(delay_ms));
98 }
99 }
100 }
101 unreachable!("Loop should return either Ok or Err");
102 }
103
104 let filepath_ref = filepath.as_ref();
105 let mut file = open_file_with_retry(filepath_ref, MAX_RETRIES, DELAY_MS)?;
106
107 let is_gzipped = filepath_ref
108 .extension()
109 .and_then(OsStr::to_str)
110 .is_some_and(|ext| ext.eq_ignore_ascii_case("gz"));
111
112 if !is_gzipped {
113 let buf_reader = BufReader::with_capacity(BUFFER_SIZE, file);
114 return Ok(ReaderBuilder::new()
115 .has_headers(true)
116 .buffer_capacity(1024 * 1024) .from_reader(Box::new(buf_reader)));
118 }
119
120 let file_size = file.metadata()?.len();
121 if file_size < 2 {
122 anyhow::bail!("File too small to be a valid gzip file");
123 }
124
125 let mut header_buf = [0u8; 2];
126 for attempt in 1..=MAX_RETRIES {
127 match file.read_exact(&mut header_buf) {
128 Ok(()) => break,
129 Err(e) => {
130 if attempt == MAX_RETRIES {
131 anyhow::bail!(
132 "Failed to read gzip header from '{}' after {MAX_RETRIES} attempts: {e}",
133 filepath_ref.display()
134 );
135 }
136 log::warn!(
137 "Attempt {attempt}/{MAX_RETRIES} failed to read header from '{}': {e}. Retrying after {DELAY_MS}ms...",
138 filepath_ref.display()
139 );
140 std::thread::sleep(Duration::from_millis(DELAY_MS));
141 }
142 }
143 }
144
145 if header_buf[0] != 0x1f || header_buf[1] != 0x8b {
146 anyhow::bail!(
147 "File '{}' has .gz extension but invalid gzip header",
148 filepath_ref.display()
149 );
150 }
151
152 for attempt in 1..=MAX_RETRIES {
153 match file.seek(SeekFrom::Start(0)) {
154 Ok(_) => break,
155 Err(e) => {
156 if attempt == MAX_RETRIES {
157 anyhow::bail!(
158 "Failed to reset file position for '{}' after {MAX_RETRIES} attempts: {e}",
159 filepath_ref.display()
160 );
161 }
162 log::warn!(
163 "Attempt {attempt}/{MAX_RETRIES} failed to seek in '{}': {e}. Retrying after {DELAY_MS}ms...",
164 filepath_ref.display()
165 );
166 std::thread::sleep(Duration::from_millis(DELAY_MS));
167 }
168 }
169 }
170
171 let buf_reader = BufReader::with_capacity(BUFFER_SIZE, file);
172 let decoder = GzDecoder::new(buf_reader);
173
174 Ok(ReaderBuilder::new()
175 .has_headers(true)
176 .buffer_capacity(1024 * 1024) .from_reader(Box::new(decoder)))
178}
179
180fn create_book_order(
181 side: OrderSide,
182 price: Option<f64>,
183 amount: Option<f64>,
184 price_precision: u8,
185 size_precision: u8,
186) -> (BookOrder, u32) {
187 match price {
188 Some(price) => (
189 BookOrder::new(
190 side,
191 parse_price(price, price_precision),
192 Quantity::new(amount.unwrap_or(0.0), size_precision),
193 0,
194 ),
195 1, ),
197 None => (NULL_ORDER, 0), }
199}
200
201fn parse_delta_record(
202 data: &TardisBookUpdateRecord,
203 price_precision: u8,
204 size_precision: u8,
205 instrument_id: Option<InstrumentId>,
206) -> anyhow::Result<OrderBookDelta> {
207 let instrument_id = match instrument_id {
208 Some(id) => id,
209 None => parse_instrument_id(&data.exchange, data.symbol),
210 };
211
212 let side = parse_order_side(&data.side);
213 let price = parse_price(data.price, price_precision);
214 let size = Quantity::new(data.amount, size_precision);
215 let order_id = 0; let order = BookOrder::new(side, price, size, order_id);
217
218 let action = parse_book_action(data.is_snapshot, size.as_f64());
219 let flags = 0; let sequence = 0; let ts_event = parse_timestamp(data.timestamp);
222 let ts_init = parse_timestamp(data.local_timestamp);
223
224 anyhow::ensure!(
225 !(action != BookAction::Delete && size.is_zero()),
226 "Invalid delta: action {action} when size zero, check size_precision ({size_precision}) vs data; {data:?}"
227 );
228
229 Ok(OrderBookDelta::new(
230 instrument_id,
231 action,
232 order,
233 flags,
234 sequence,
235 ts_event,
236 ts_init,
237 ))
238}
239
240fn parse_quote_record(
241 data: &TardisQuoteRecord,
242 price_precision: u8,
243 size_precision: u8,
244 instrument_id: Option<InstrumentId>,
245) -> QuoteTick {
246 let instrument_id = match instrument_id {
247 Some(id) => id,
248 None => parse_instrument_id(&data.exchange, data.symbol),
249 };
250
251 let bid_price = parse_price(data.bid_price.unwrap_or(0.0), price_precision);
252 let ask_price = parse_price(data.ask_price.unwrap_or(0.0), price_precision);
253 let bid_size = Quantity::new(data.bid_amount.unwrap_or(0.0), size_precision);
254 let ask_size = Quantity::new(data.ask_amount.unwrap_or(0.0), size_precision);
255 let ts_event = parse_timestamp(data.timestamp);
256 let ts_init = parse_timestamp(data.local_timestamp);
257
258 QuoteTick::new(
259 instrument_id,
260 bid_price,
261 ask_price,
262 bid_size,
263 ask_size,
264 ts_event,
265 ts_init,
266 )
267}
268
269fn parse_trade_record(
270 data: &TardisTradeRecord,
271 size: Quantity,
272 price_precision: u8,
273 instrument_id: Option<InstrumentId>,
274) -> TradeTick {
275 let instrument_id = match instrument_id {
276 Some(id) => id,
277 None => parse_instrument_id(&data.exchange, data.symbol),
278 };
279
280 let price = parse_price(data.price, price_precision);
281 let aggressor_side = parse_aggressor_side(&data.side);
282 let ts_event = parse_timestamp(data.timestamp);
283 let ts_init = parse_timestamp(data.local_timestamp);
284 let trade_id = if data.id.is_empty() {
285 derive_trade_id(
286 data.symbol,
287 ts_event.as_u64(),
288 data.price,
289 data.amount,
290 &data.side,
291 )
292 } else {
293 TradeId::new(&data.id)
294 };
295
296 TradeTick::new(
297 instrument_id,
298 price,
299 size,
300 aggressor_side,
301 trade_id,
302 ts_event,
303 ts_init,
304 )
305}
306
307fn parse_derivative_ticker_record(
308 data: &TardisDerivativeTickerRecord,
309 instrument_id: Option<InstrumentId>,
310) -> Option<FundingRateUpdate> {
311 let funding_rate = data.funding_rate?;
313
314 let instrument_id = match instrument_id {
315 Some(id) => id,
316 None => parse_instrument_id(&data.exchange, data.symbol),
317 };
318
319 let rate = Decimal::try_from(funding_rate).ok()?;
320 let next_funding_ns = data.funding_timestamp.map(parse_timestamp);
321 let ts_event = parse_timestamp(data.timestamp);
322 let ts_init = parse_timestamp(data.local_timestamp);
323
324 Some(FundingRateUpdate::new(
325 instrument_id,
326 rate,
327 None,
328 next_funding_ns,
329 ts_event,
330 ts_init,
331 ))
332}
333
334fn parse_options_chain_record(
335 data: &TardisOptionsChainRecord,
336 instrument_id: InstrumentId,
337) -> OptionGreeks {
338 OptionGreeks {
339 instrument_id,
340 convention: GreeksConvention::BlackScholes,
341 greeks: OptionGreekValues {
342 delta: data.delta.unwrap_or(0.0),
343 gamma: data.gamma.unwrap_or(0.0),
344 vega: data.vega.unwrap_or(0.0),
345 theta: data.theta.unwrap_or(0.0),
346 rho: data.rho.unwrap_or(0.0),
347 },
348 mark_iv: data.mark_iv,
349 bid_iv: data.bid_iv,
350 ask_iv: data.ask_iv,
351 underlying_price: data.underlying_price,
352 open_interest: data.open_interest,
353 ts_event: parse_timestamp(data.timestamp),
354 ts_init: parse_timestamp(data.local_timestamp),
355 }
356}
357
358fn parse_options_chain_record_as_quote(
359 data: &TardisOptionsChainRecord,
360 price_precision: u8,
361 size_precision: u8,
362 instrument_id: InstrumentId,
363) -> anyhow::Result<Option<QuoteTick>> {
364 let (Some(bid_price), Some(bid_amount), Some(ask_price), Some(ask_amount)) = (
365 data.bid_price,
366 data.bid_amount,
367 data.ask_price,
368 data.ask_amount,
369 ) else {
370 return Ok(None);
371 };
372
373 let bid_price = Price::new_checked(bid_price, price_precision)?;
374 let ask_price = Price::new_checked(ask_price, price_precision)?;
375 let bid_size = Quantity::non_zero_checked(bid_amount, size_precision)?;
376 let ask_size = Quantity::non_zero_checked(ask_amount, size_precision)?;
377
378 Ok(Some(QuoteTick::new(
379 instrument_id,
380 bid_price,
381 ask_price,
382 bid_size,
383 ask_size,
384 parse_timestamp(data.timestamp),
385 parse_timestamp(data.local_timestamp),
386 )))
387}
388
389fn matches_underlying_filter(symbol: &str, underlyings: Option<&[String]>) -> bool {
390 underlyings.is_none_or(|underlyings| underlyings.iter().any(|u| symbol.starts_with(u)))
391}
392
393fn normalize_underlying_filters(underlyings: Option<Vec<String>>) -> Option<Vec<String>> {
394 underlyings
395 .map(|values| {
396 values
397 .into_iter()
398 .map(|value| value.trim().to_uppercase())
399 .filter(|value| !value.is_empty())
400 .collect::<Vec<_>>()
401 })
402 .filter(|values| !values.is_empty())
403}