Skip to main content

nautilus_tardis/csv/
mod.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
16pub 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(); // Stack allocation
62    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; // 8MB buffer for large files
76
77    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) // 1MB CSV buffer
117            .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) // 1MB CSV buffer
177        .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, // Count set to 1 if order exists
196        ),
197        None => (NULL_ORDER, 0), // NULL_ORDER if price is None
198    }
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; // Not applicable for L2 data
216    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; // Will be set later if needed
220    let sequence = 0; // Sequence not available
221    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    // Only create funding rate update if we have funding rate data
312    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}