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