Skip to main content

nautilus_tardis/csv/
load.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::{error::Error, path::Path};
17
18use ahash::AHashMap;
19use csv::StringRecord;
20use nautilus_core::UnixNanos;
21use nautilus_model::{
22    data::{
23        DEPTH10_LEN, Data, FundingRateUpdate, NULL_ORDER, OrderBookDelta, OrderBookDepth,
24        QuoteTick, TradeTick,
25    },
26    enums::{OrderSide, RecordFlag},
27    identifiers::InstrumentId,
28    types::{Quantity, fixed::FIXED_PRECISION},
29};
30
31use crate::{
32    common::parse::{parse_instrument_id, parse_timestamp},
33    csv::{
34        create_book_order, create_csv_reader, infer_precision, matches_underlying_filter,
35        normalize_underlying_filters, parse_delta_record, parse_derivative_ticker_record,
36        parse_options_chain_record, parse_options_chain_record_as_quote, parse_quote_record,
37        parse_trade_record,
38        record::{
39            TardisBookUpdateRecord, TardisDerivativeTickerRecord, TardisOptionsChainRecord,
40            TardisOrderBookSnapshot5Record, TardisOrderBookSnapshot25Record, TardisQuoteRecord,
41            TardisTradeRecord,
42        },
43    },
44};
45
46#[derive(Debug, Clone, Copy)]
47pub(in crate::csv) struct OptionsChainPrecision {
48    pub(in crate::csv) price: u8,
49    pub(in crate::csv) size: u8,
50}
51
52impl OptionsChainPrecision {
53    pub(in crate::csv) const fn new(
54        price_precision: Option<u8>,
55        size_precision: Option<u8>,
56    ) -> Self {
57        Self {
58            price: match price_precision {
59                Some(precision) => precision,
60                None => 0,
61            },
62            size: match size_precision {
63                Some(precision) => precision,
64                None => 0,
65            },
66        }
67    }
68
69    pub(in crate::csv) fn update(
70        &mut self,
71        record: &TardisOptionsChainRecord,
72        price_precision: Option<u8>,
73        size_precision: Option<u8>,
74    ) {
75        if price_precision.is_none() {
76            for value in [record.last_price, record.bid_price, record.ask_price]
77                .into_iter()
78                .flatten()
79            {
80                update_precision_if_needed(&mut self.price, value, price_precision);
81            }
82        }
83
84        if size_precision.is_none() {
85            for value in [record.bid_amount, record.ask_amount].into_iter().flatten() {
86                update_precision_if_needed(&mut self.size, value, size_precision);
87            }
88        }
89    }
90}
91
92fn update_precision_if_needed(current: &mut u8, value: f64, explicit: Option<u8>) -> bool {
93    if explicit.is_some() {
94        return false;
95    }
96
97    let inferred = infer_precision(value).min(FIXED_PRECISION);
98    if inferred > *current {
99        *current = inferred;
100        true
101    } else {
102        false
103    }
104}
105
106fn update_deltas_precision(
107    deltas: &mut [OrderBookDelta],
108    price_precision: Option<u8>,
109    size_precision: Option<u8>,
110    current_price_precision: u8,
111    current_size_precision: u8,
112) {
113    for delta in deltas {
114        if price_precision.is_none() {
115            delta.order.price.precision = current_price_precision;
116        }
117
118        if size_precision.is_none() {
119            delta.order.size.precision = current_size_precision;
120        }
121    }
122}
123
124fn update_quotes_precision(
125    quotes: &mut [QuoteTick],
126    price_precision: Option<u8>,
127    size_precision: Option<u8>,
128    current_price_precision: u8,
129    current_size_precision: u8,
130) {
131    for quote in quotes {
132        if price_precision.is_none() {
133            quote.bid_price.precision = current_price_precision;
134            quote.ask_price.precision = current_price_precision;
135        }
136
137        if size_precision.is_none() {
138            quote.bid_size.precision = current_size_precision;
139            quote.ask_size.precision = current_size_precision;
140        }
141    }
142}
143
144fn update_trades_precision(
145    trades: &mut [TradeTick],
146    price_precision: Option<u8>,
147    size_precision: Option<u8>,
148    current_price_precision: u8,
149    current_size_precision: u8,
150) {
151    for trade in trades {
152        if price_precision.is_none() {
153            trade.price.precision = current_price_precision;
154        }
155
156        if size_precision.is_none() {
157            trade.size.precision = current_size_precision;
158        }
159    }
160}
161
162/// Loads [`OrderBookDelta`]s from a Tardis format CSV at the given `filepath`,
163/// automatically applying `GZip` decompression for files ending in ".gz".
164/// Load order book delta records from a CSV or gzipped CSV file.
165///
166/// # Errors
167///
168/// Returns an error if the file cannot be opened, read, or parsed as CSV.
169pub fn load_deltas<P: AsRef<Path>>(
170    filepath: P,
171    price_precision: Option<u8>,
172    size_precision: Option<u8>,
173    instrument_id: Option<InstrumentId>,
174    limit: Option<usize>,
175) -> Result<Vec<OrderBookDelta>, Box<dyn Error>> {
176    // Estimate capacity for Vec pre-allocation
177    let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
178    let mut deltas: Vec<OrderBookDelta> = Vec::with_capacity(estimated_capacity);
179
180    let mut current_price_precision = price_precision.unwrap_or(0);
181    let mut current_size_precision = size_precision.unwrap_or(0);
182    let mut last_ts_init: Option<UnixNanos> = None;
183    let mut last_is_snapshot = false;
184    let mut seen_first_snapshot = false;
185    let mut skipped_before_snapshot: usize = 0;
186
187    let mut reader = create_csv_reader(filepath)?;
188    let mut record = StringRecord::new();
189
190    while reader.read_record(&mut record)? {
191        if let Some(limit) = limit
192            && deltas.len() >= limit
193        {
194            break;
195        }
196
197        let data: TardisBookUpdateRecord = record.deserialize(None)?;
198
199        // Rows before the first snapshot are pre-snapshot orphans and must be skipped, see
200        // https://docs.tardis.dev/faq/order-books
201        if !seen_first_snapshot {
202            if !data.is_snapshot {
203                skipped_before_snapshot += 1;
204                continue;
205            }
206
207            if skipped_before_snapshot > 0 {
208                log::warn!(
209                    "Skipped {skipped_before_snapshot} pre-snapshot buffered delta record(s) for \
210                     {}/{} (received before the first snapshot row, see \
211                     https://docs.tardis.dev/faq/order-books)",
212                    data.exchange,
213                    data.symbol,
214                );
215            }
216            seen_first_snapshot = true;
217        }
218
219        update_precision_if_needed(&mut current_price_precision, data.price, price_precision);
220        update_precision_if_needed(&mut current_size_precision, data.amount, size_precision);
221
222        let ts_event = parse_timestamp(data.timestamp);
223        let ts_init = parse_timestamp(data.local_timestamp);
224
225        // Insert CLEAR on snapshot boundary to reset order book state.
226        // Some venues emit every book event as a full snapshot, so a new
227        // snapshot message must also reset the previous snapshot state.
228        let starts_new_snapshot =
229            data.is_snapshot && (!last_is_snapshot || last_ts_init != Some(ts_init));
230
231        if starts_new_snapshot {
232            let clear_instrument_id =
233                instrument_id.unwrap_or_else(|| parse_instrument_id(&data.exchange, data.symbol));
234
235            if last_ts_init != Some(ts_init)
236                && let Some(last_delta) = deltas.last_mut()
237            {
238                last_delta.flags = RecordFlag::F_LAST as u8;
239            }
240            last_ts_init = Some(ts_init);
241
242            let clear_delta = OrderBookDelta::clear(clear_instrument_id, 0, ts_event, ts_init);
243            deltas.push(clear_delta);
244
245            if let Some(limit) = limit
246                && deltas.len() >= limit
247            {
248                break;
249            }
250        }
251        last_is_snapshot = data.is_snapshot;
252
253        let delta = match parse_delta_record(
254            &data,
255            current_price_precision,
256            current_size_precision,
257            instrument_id,
258        ) {
259            Ok(d) => d,
260            Err(e) => {
261                log::warn!("Skipping invalid delta record: {e}");
262                continue;
263            }
264        };
265
266        let ts_init = delta.ts_init;
267        if last_ts_init != Some(ts_init)
268            && let Some(last_delta) = deltas.last_mut()
269        {
270            last_delta.flags = RecordFlag::F_LAST as u8;
271        }
272
273        last_ts_init = Some(ts_init);
274
275        deltas.push(delta);
276    }
277
278    if !seen_first_snapshot && skipped_before_snapshot > 0 {
279        log::warn!(
280            "No snapshot row found in Tardis CSV: all {skipped_before_snapshot} row(s) were \
281             pre-snapshot buffered records and have been skipped, zero deltas will be produced \
282             (see https://docs.tardis.dev/faq/order-books)"
283        );
284    }
285
286    // Set F_LAST flag for final delta
287    if let Some(last_delta) = deltas.last_mut() {
288        last_delta.flags = RecordFlag::F_LAST as u8;
289    }
290
291    // Update all deltas to use the final (maximum) precision discovered
292    // This is done once at the end instead of on every precision change (O(n) vs O(n²))
293    update_deltas_precision(
294        &mut deltas,
295        price_precision,
296        size_precision,
297        current_price_precision,
298        current_size_precision,
299    );
300
301    Ok(deltas)
302}
303
304/// Loads [`OrderBookDepth`]s from a Tardis format CSV at the given `filepath`,
305/// automatically applying `GZip` decompression for files ending in ".gz".
306/// Load order book depth snapshots (5-level) from a CSV or gzipped CSV file.
307///
308/// # Errors
309///
310/// Returns an error if the file cannot be opened, read, or parsed as CSV.
311///
312/// # Panics
313///
314/// Panics if a record level cannot be parsed to depth.
315pub fn load_depth_from_snapshot5<P: AsRef<Path>>(
316    filepath: P,
317    price_precision: Option<u8>,
318    size_precision: Option<u8>,
319    instrument_id: Option<InstrumentId>,
320    limit: Option<usize>,
321) -> Result<Vec<OrderBookDepth>, Box<dyn Error>> {
322    // Estimate capacity for Vec pre-allocation
323    let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
324    let mut depths: Vec<OrderBookDepth> = Vec::with_capacity(estimated_capacity);
325
326    let mut current_price_precision = price_precision.unwrap_or(0);
327    let mut current_size_precision = size_precision.unwrap_or(0);
328
329    let mut reader = create_csv_reader(filepath)?;
330    let mut record = StringRecord::new();
331
332    while reader.read_record(&mut record)? {
333        let data: TardisOrderBookSnapshot5Record = record.deserialize(None)?;
334
335        // Update precisions dynamically if not explicitly set
336        let mut precision_updated = false;
337
338        if price_precision.is_none()
339            && let Some(bid_price) = data.bids_0_price
340        {
341            let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
342            if inferred_price_precision > current_price_precision {
343                current_price_precision = inferred_price_precision;
344                precision_updated = true;
345            }
346        }
347
348        if size_precision.is_none()
349            && let Some(bid_amount) = data.bids_0_amount
350        {
351            let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
352            if inferred_size_precision > current_size_precision {
353                current_size_precision = inferred_size_precision;
354                precision_updated = true;
355            }
356        }
357
358        // If precision increased, update all previous depths
359        if precision_updated {
360            for depth in &mut depths {
361                for order in depth.bids.iter_mut().chain(depth.asks.iter_mut()) {
362                    if price_precision.is_none() {
363                        order.price.precision = current_price_precision;
364                    }
365
366                    if size_precision.is_none() {
367                        order.size.precision = current_size_precision;
368                    }
369                }
370            }
371        }
372
373        let instrument_id = match &instrument_id {
374            Some(id) => *id,
375            None => parse_instrument_id(&data.exchange, data.symbol),
376        };
377        // Mark as both snapshot and last (consistent with streaming implementation)
378        let flags = RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8;
379        let sequence = 0; // Sequence not available
380        let ts_event = parse_timestamp(data.timestamp);
381        let ts_init = parse_timestamp(data.local_timestamp);
382
383        // Initialize empty arrays
384        let mut bids = [NULL_ORDER; DEPTH10_LEN];
385        let mut asks = [NULL_ORDER; DEPTH10_LEN];
386        let mut bid_counts = [0u32; DEPTH10_LEN];
387        let mut ask_counts = [0u32; DEPTH10_LEN];
388
389        for i in 0..=4 {
390            // Create bids
391            let (bid_order, bid_count) = create_book_order(
392                OrderSide::Buy,
393                match i {
394                    0 => data.bids_0_price,
395                    1 => data.bids_1_price,
396                    2 => data.bids_2_price,
397                    3 => data.bids_3_price,
398                    4 => data.bids_4_price,
399                    _ => unreachable!("i is constrained to 0..=4 by loop"),
400                },
401                match i {
402                    0 => data.bids_0_amount,
403                    1 => data.bids_1_amount,
404                    2 => data.bids_2_amount,
405                    3 => data.bids_3_amount,
406                    4 => data.bids_4_amount,
407                    _ => unreachable!("i is constrained to 0..=4 by loop"),
408                },
409                current_price_precision,
410                current_size_precision,
411            );
412            bids[i] = bid_order;
413            bid_counts[i] = bid_count;
414
415            // Create asks
416            let (ask_order, ask_count) = create_book_order(
417                OrderSide::Sell,
418                match i {
419                    0 => data.asks_0_price,
420                    1 => data.asks_1_price,
421                    2 => data.asks_2_price,
422                    3 => data.asks_3_price,
423                    4 => data.asks_4_price,
424                    _ => None, // Unreachable, but for safety
425                },
426                match i {
427                    0 => data.asks_0_amount,
428                    1 => data.asks_1_amount,
429                    2 => data.asks_2_amount,
430                    3 => data.asks_3_amount,
431                    4 => data.asks_4_amount,
432                    _ => None, // Unreachable, but for safety
433                },
434                current_price_precision,
435                current_size_precision,
436            );
437            asks[i] = ask_order;
438            ask_counts[i] = ask_count;
439        }
440
441        let depth = OrderBookDepth::new(
442            instrument_id,
443            bids,
444            asks,
445            bid_counts,
446            ask_counts,
447            flags,
448            sequence,
449            ts_event,
450            ts_init,
451        );
452
453        depths.push(depth);
454
455        if let Some(limit) = limit
456            && depths.len() >= limit
457        {
458            break;
459        }
460    }
461
462    Ok(depths)
463}
464
465/// Loads [`OrderBookDepth`]s from a Tardis format CSV at the given `filepath`,
466/// automatically applying `GZip` decompression for files ending in ".gz".
467/// Load order book depth snapshots (25-level) from a CSV or gzipped CSV file.
468///
469/// # Errors
470///
471/// Returns an error if the file cannot be opened, read, or parsed as CSV.
472pub fn load_depth_from_snapshot25<P: AsRef<Path>>(
473    filepath: P,
474    price_precision: Option<u8>,
475    size_precision: Option<u8>,
476    instrument_id: Option<InstrumentId>,
477    limit: Option<usize>,
478) -> Result<Vec<OrderBookDepth>, Box<dyn Error>> {
479    // Estimate capacity for Vec pre-allocation
480    let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
481    let mut depths: Vec<OrderBookDepth> = Vec::with_capacity(estimated_capacity);
482
483    let mut current_price_precision = price_precision.unwrap_or(0);
484    let mut current_size_precision = size_precision.unwrap_or(0);
485    let mut reader = create_csv_reader(filepath)?;
486    let mut record = StringRecord::new();
487
488    while reader.read_record(&mut record)? {
489        let data: TardisOrderBookSnapshot25Record = record.deserialize(None)?;
490
491        // Update precisions dynamically if not explicitly set
492        let mut precision_updated = false;
493
494        if price_precision.is_none()
495            && let Some(bid_price) = data.bids_0_price
496        {
497            let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
498            if inferred_price_precision > current_price_precision {
499                current_price_precision = inferred_price_precision;
500                precision_updated = true;
501            }
502        }
503
504        if size_precision.is_none()
505            && let Some(bid_amount) = data.bids_0_amount
506        {
507            let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
508            if inferred_size_precision > current_size_precision {
509                current_size_precision = inferred_size_precision;
510                precision_updated = true;
511            }
512        }
513
514        // If precision increased, update all previous depths
515        if precision_updated {
516            for depth in &mut depths {
517                for order in depth.bids.iter_mut().chain(depth.asks.iter_mut()) {
518                    if price_precision.is_none() {
519                        order.price.precision = current_price_precision;
520                    }
521
522                    if size_precision.is_none() {
523                        order.size.precision = current_size_precision;
524                    }
525                }
526            }
527        }
528
529        let instrument_id = match &instrument_id {
530            Some(id) => *id,
531            None => parse_instrument_id(&data.exchange, data.symbol),
532        };
533        // Mark as both snapshot and last (consistent with streaming implementation)
534        let flags = RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8;
535        let sequence = 0; // Sequence not available
536        let ts_event = parse_timestamp(data.timestamp);
537        let ts_init = parse_timestamp(data.local_timestamp);
538
539        // Initialize empty arrays for all 25 levels
540        let mut bids = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
541        let mut asks = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
542        let mut bid_counts = [0u32; TardisOrderBookSnapshot25Record::LEVELS];
543        let mut ask_counts = [0u32; TardisOrderBookSnapshot25Record::LEVELS];
544
545        // Fill all 25 levels from the 25-level record
546        for i in 0..TardisOrderBookSnapshot25Record::LEVELS {
547            // Create bids
548            let (bid_price, bid_amount) = data.bid_level(i);
549            let (bid_order, bid_count) = create_book_order(
550                OrderSide::Buy,
551                bid_price,
552                bid_amount,
553                current_price_precision,
554                current_size_precision,
555            );
556            bids[i] = bid_order;
557            bid_counts[i] = bid_count;
558
559            // Create asks
560            let (ask_price, ask_amount) = data.ask_level(i);
561            let (ask_order, ask_count) = create_book_order(
562                OrderSide::Sell,
563                ask_price,
564                ask_amount,
565                current_price_precision,
566                current_size_precision,
567            );
568            asks[i] = ask_order;
569            ask_counts[i] = ask_count;
570        }
571
572        let depth = OrderBookDepth::new(
573            instrument_id,
574            bids,
575            asks,
576            bid_counts,
577            ask_counts,
578            flags,
579            sequence,
580            ts_event,
581            ts_init,
582        );
583
584        depths.push(depth);
585
586        if let Some(limit) = limit
587            && depths.len() >= limit
588        {
589            break;
590        }
591    }
592
593    Ok(depths)
594}
595
596/// Loads [`QuoteTick`]s from a Tardis format CSV at the given `filepath`,
597/// automatically applying `GZip` decompression for files ending in ".gz".
598/// Load quote ticks from a CSV or gzipped CSV file.
599///
600/// # Errors
601///
602/// Returns an error if the file cannot be opened, read, or parsed as CSV.
603pub fn load_quotes<P: AsRef<Path>>(
604    filepath: P,
605    price_precision: Option<u8>,
606    size_precision: Option<u8>,
607    instrument_id: Option<InstrumentId>,
608    limit: Option<usize>,
609) -> Result<Vec<QuoteTick>, Box<dyn Error>> {
610    // Estimate capacity for Vec pre-allocation
611    let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
612    let mut quotes: Vec<QuoteTick> = Vec::with_capacity(estimated_capacity);
613
614    let mut current_price_precision = price_precision.unwrap_or(0);
615    let mut current_size_precision = size_precision.unwrap_or(0);
616    let mut reader = create_csv_reader(filepath)?;
617    let mut record = StringRecord::new();
618
619    while reader.read_record(&mut record)? {
620        let data: TardisQuoteRecord = record.deserialize(None)?;
621
622        if price_precision.is_none()
623            && let Some(bid_price) = data.bid_price
624        {
625            let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
626            if inferred_price_precision > current_price_precision {
627                current_price_precision = inferred_price_precision;
628            }
629        }
630
631        if size_precision.is_none()
632            && let Some(bid_amount) = data.bid_amount
633        {
634            let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
635            if inferred_size_precision > current_size_precision {
636                current_size_precision = inferred_size_precision;
637            }
638        }
639
640        let quote = parse_quote_record(
641            &data,
642            current_price_precision,
643            current_size_precision,
644            instrument_id,
645        );
646
647        quotes.push(quote);
648
649        if let Some(limit) = limit
650            && quotes.len() >= limit
651        {
652            break;
653        }
654    }
655
656    // Update all quotes to use the final (maximum) precision discovered
657    // This is done once at the end instead of on every precision change (O(n) vs O(n²))
658    update_quotes_precision(
659        &mut quotes,
660        price_precision,
661        size_precision,
662        current_price_precision,
663        current_size_precision,
664    );
665
666    Ok(quotes)
667}
668
669/// Loads [`TradeTick`]s from a Tardis format CSV at the given `filepath`,
670/// automatically applying `GZip` decompression for files ending in ".gz".
671/// Load trade ticks from a CSV or gzipped CSV file.
672///
673/// # Errors
674///
675/// Returns an error if the file cannot be opened, read, or parsed as CSV.
676pub fn load_trades<P: AsRef<Path>>(
677    filepath: P,
678    price_precision: Option<u8>,
679    size_precision: Option<u8>,
680    instrument_id: Option<InstrumentId>,
681    limit: Option<usize>,
682) -> Result<Vec<TradeTick>, Box<dyn Error>> {
683    // Estimate capacity for Vec pre-allocation
684    let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
685    let mut trades: Vec<TradeTick> = Vec::with_capacity(estimated_capacity);
686
687    let mut current_price_precision = price_precision.unwrap_or(0);
688    let mut current_size_precision = size_precision.unwrap_or(0);
689    let mut reader = create_csv_reader(filepath)?;
690    let mut record = StringRecord::new();
691
692    while reader.read_record(&mut record)? {
693        let data: TardisTradeRecord = record.deserialize(None)?;
694
695        if price_precision.is_none() {
696            let inferred_price_precision = infer_precision(data.price).min(FIXED_PRECISION);
697            if inferred_price_precision > current_price_precision {
698                current_price_precision = inferred_price_precision;
699            }
700        }
701
702        if size_precision.is_none() {
703            let inferred_size_precision = infer_precision(data.amount).min(FIXED_PRECISION);
704            if inferred_size_precision > current_size_precision {
705                current_size_precision = inferred_size_precision;
706            }
707        }
708
709        let size = Quantity::new_checked(data.amount, current_size_precision)?;
710
711        if size.is_positive() {
712            let trade = parse_trade_record(&data, size, current_price_precision, instrument_id);
713
714            trades.push(trade);
715
716            if let Some(limit) = limit
717                && trades.len() >= limit
718            {
719                break;
720            }
721        } else {
722            log::warn!("Skipping zero-sized trade: {data:?}");
723        }
724    }
725
726    // Update all trades to use the final (maximum) precision discovered
727    // This is done once at the end instead of on every precision change (O(n) vs O(n²))
728    update_trades_precision(
729        &mut trades,
730        price_precision,
731        size_precision,
732        current_price_precision,
733        current_size_precision,
734    );
735
736    Ok(trades)
737}
738
739/// Loads [`FundingRateUpdate`]s from a Tardis format derivative ticker CSV at the given `filepath`,
740/// automatically applying `GZip` decompression for files ending in ".gz".
741///
742/// This function parses the `funding_rate` and `funding_timestamp` fields from derivative ticker
743/// data to create funding rate updates.
744///
745/// # Errors
746///
747/// Returns an error if the file cannot be opened, read, or parsed as CSV.
748pub fn load_funding_rates<P: AsRef<Path>>(
749    filepath: P,
750    instrument_id: Option<InstrumentId>,
751    limit: Option<usize>,
752) -> Result<Vec<FundingRateUpdate>, Box<dyn Error>> {
753    // Estimate capacity for Vec pre-allocation
754    let estimated_capacity = limit.unwrap_or(100_000).min(1_000_000);
755    let mut funding_rates: Vec<FundingRateUpdate> = Vec::with_capacity(estimated_capacity);
756
757    let mut reader = create_csv_reader(filepath)?;
758    let mut record = StringRecord::new();
759
760    while reader.read_record(&mut record)? {
761        let data: TardisDerivativeTickerRecord = record.deserialize(None)?;
762
763        // Parse to funding rate update (returns None if no funding data)
764        if let Some(funding_rate) = parse_derivative_ticker_record(&data, instrument_id) {
765            funding_rates.push(funding_rate);
766
767            if let Some(limit) = limit
768                && funding_rates.len() >= limit
769            {
770                break;
771            }
772        }
773    }
774
775    Ok(funding_rates)
776}
777
778/// Loads option chain rows from a Tardis `options_chain` CSV file.
779///
780/// Returns quote ticks before option greeks for rows with a complete best bid/offer. Rows missing
781/// any best bid/offer field still return option greeks.
782///
783/// # Errors
784///
785/// Returns an error if the file cannot be opened, read, or parsed as CSV, or if a complete best
786/// bid/offer row contains invalid price or size values.
787pub fn load_options_chain<P: AsRef<Path>>(
788    filepath: P,
789    underlyings: Option<Vec<String>>,
790    price_precision: Option<u8>,
791    size_precision: Option<u8>,
792    limit: Option<usize>,
793) -> Result<Vec<Data>, Box<dyn Error>> {
794    let underlyings = normalize_underlying_filters(underlyings);
795    let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
796    let mut records: Vec<TardisOptionsChainRecord> = Vec::with_capacity(estimated_capacity);
797    let mut precision_by_instrument: AHashMap<InstrumentId, OptionsChainPrecision> =
798        AHashMap::new();
799
800    let mut reader = create_csv_reader(filepath)?;
801    let mut record = StringRecord::new();
802
803    while reader.read_record(&mut record)? {
804        if let Some(underlyings) = underlyings.as_deref() {
805            let Some(symbol) = record.get(1) else {
806                continue;
807            };
808            let symbol = symbol.to_uppercase();
809            if !matches_underlying_filter(&symbol, Some(underlyings)) {
810                continue;
811            }
812        }
813
814        let data: TardisOptionsChainRecord = record.deserialize(None)?;
815        let instrument_id = parse_instrument_id(&data.exchange, data.symbol);
816        precision_by_instrument
817            .entry(instrument_id)
818            .or_insert_with(|| OptionsChainPrecision::new(price_precision, size_precision))
819            .update(&data, price_precision, size_precision);
820        records.push(data);
821
822        if let Some(limit) = limit
823            && records.len() >= limit
824        {
825            break;
826        }
827    }
828
829    let mut output = Vec::with_capacity(records.len() * 2);
830    for record in records {
831        let instrument_id = parse_instrument_id(&record.exchange, record.symbol);
832        let precision = precision_by_instrument
833            .get(&instrument_id)
834            .copied()
835            .unwrap_or_else(|| OptionsChainPrecision::new(price_precision, size_precision));
836
837        if let Some(quote) = parse_options_chain_record_as_quote(
838            &record,
839            precision.price,
840            precision.size,
841            instrument_id,
842        )? {
843            output.push(Data::Quote(quote));
844        }
845
846        output.push(Data::OptionGreeks(parse_options_chain_record(
847            &record,
848            instrument_id,
849        )));
850    }
851
852    Ok(output)
853}
854
855#[cfg(test)]
856mod tests {
857    use std::fs;
858    #[cfg(feature = "arrow")]
859    use std::{fs::File, sync::Arc};
860
861    #[cfg(feature = "arrow")]
862    use nautilus_core::paths::get_test_data_path as get_test_data_root;
863    use nautilus_model::{
864        enums::{AggressorSide, BookAction, OrderSide},
865        identifiers::{InstrumentId, TradeId},
866        types::Price,
867    };
868    #[cfg(feature = "arrow")]
869    use nautilus_serialization::arrow::{ArrowSchemaProvider, EncodeToRecordBatch};
870    use nautilus_testkit::common::{
871        get_tardis_binance_snapshot5_path, get_tardis_binance_snapshot25_path,
872        get_tardis_bitmex_trades_path, get_tardis_deribit_book_l2_path,
873        get_tardis_huobi_quotes_path,
874    };
875    #[cfg(feature = "arrow")]
876    use parquet::{arrow::ArrowWriter, file::properties::WriterProperties};
877    use rstest::*;
878    use rust_decimal_macros::dec;
879
880    use super::*;
881    use crate::common::{parse::parse_price, testing::get_test_data_path};
882
883    #[rstest]
884    #[case(0.0, 0)]
885    #[case(42.0, 0)]
886    #[case(0.1, 1)]
887    #[case(0.25, 2)]
888    #[case(123.0001, 4)]
889    #[case(-42.987654321,       9)]
890    #[case(1.234_567_890_123, 12)]
891    fn test_infer_precision(#[case] input: f64, #[case] expected: u8) {
892        assert_eq!(infer_precision(input), expected);
893    }
894
895    #[rstest]
896    pub fn test_dynamic_precision_inference() {
897        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
898binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
899binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0
900binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50000.12,1.5
901binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49999.123,3.0
902binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50000.1234,0.5";
903
904        let temp_file = std::env::temp_dir().join("test_dynamic_precision.csv");
905        std::fs::write(&temp_file, csv_data).unwrap();
906
907        let deltas = load_deltas(&temp_file, None, None, None, None).unwrap();
908
909        // 5 data rows + 1 CLEAR delta at start (first row is snapshot)
910        assert_eq!(deltas.len(), 6);
911
912        // Skip the CLEAR delta at index 0
913        for (i, delta) in deltas.iter().skip(1).enumerate() {
914            assert_eq!(
915                delta.order.price.precision, 4,
916                "Price precision should be 4 for delta {i}",
917            );
918            assert_eq!(
919                delta.order.size.precision, 1,
920                "Size precision should be 1 for delta {i}",
921            );
922        }
923
924        // Test exact values to ensure retroactive precision updates work correctly
925        // Index 0 is CLEAR, data starts at index 1
926        assert_eq!(deltas[0].action, BookAction::Clear);
927
928        assert_eq!(deltas[1].order.price, parse_price(50000.0, 4));
929        assert_eq!(deltas[1].order.size, Quantity::new(1.0, 1));
930
931        assert_eq!(deltas[2].order.price, parse_price(49999.5, 4));
932        assert_eq!(deltas[2].order.size, Quantity::new(2.0, 1));
933
934        assert_eq!(deltas[3].order.price, parse_price(50000.12, 4));
935        assert_eq!(deltas[3].order.size, Quantity::new(1.5, 1));
936
937        assert_eq!(deltas[4].order.price, parse_price(49999.123, 4));
938        assert_eq!(deltas[4].order.size, Quantity::new(3.0, 1));
939
940        assert_eq!(deltas[5].order.price, parse_price(50000.1234, 4));
941        assert_eq!(deltas[5].order.size, Quantity::new(0.5, 1));
942
943        assert_eq!(
944            deltas[1].order.price.precision,
945            deltas[5].order.price.precision
946        );
947        assert_eq!(
948            deltas[1].order.size.precision,
949            deltas[3].order.size.precision
950        );
951
952        std::fs::remove_file(&temp_file).ok();
953    }
954
955    #[rstest]
956    #[case(Some(1), Some(0))] // Explicit precisions
957    #[case(None, None)] // Inferred precisions
958    pub fn test_read_deltas(
959        #[case] price_precision: Option<u8>,
960        #[case] size_precision: Option<u8>,
961    ) {
962        let filepath = get_tardis_deribit_book_l2_path();
963        let deltas =
964            load_deltas(filepath, price_precision, size_precision, None, Some(100)).unwrap();
965
966        // 15 data rows + 1 CLEAR delta at start (first row is snapshot)
967        assert_eq!(deltas.len(), 16);
968
969        // Index 0 is CLEAR delta
970        assert_eq!(deltas[0].action, BookAction::Clear);
971
972        // Index 1 is first data delta
973        assert_eq!(
974            deltas[1].instrument_id,
975            InstrumentId::from("BTC-PERPETUAL.DERIBIT")
976        );
977        assert_eq!(deltas[1].action, BookAction::Add);
978        assert_eq!(deltas[1].order.side, OrderSide::Sell.into());
979        assert_eq!(deltas[1].order.price, Price::from("6421.5"));
980        assert_eq!(deltas[1].order.size, Quantity::from("18640"));
981        assert_eq!(deltas[1].flags, 0);
982        assert_eq!(deltas[1].sequence, 0);
983        assert_eq!(deltas[1].ts_event, 1585699200245000000);
984        assert_eq!(deltas[1].ts_init, 1585699200355684000);
985    }
986
987    #[rstest]
988    #[case(Some(2), Some(3))] // Explicit precisions
989    #[case(None, None)] // Inferred precisions
990    pub fn test_read_depths_from_snapshot5(
991        #[case] price_precision: Option<u8>,
992        #[case] size_precision: Option<u8>,
993    ) {
994        let filepath = get_tardis_binance_snapshot5_path();
995        let depths =
996            load_depth_from_snapshot5(filepath, price_precision, size_precision, None, Some(100))
997                .unwrap();
998
999        assert_eq!(depths.len(), 10);
1000        assert_eq!(
1001            depths[0].instrument_id,
1002            InstrumentId::from("BTCUSDT.BINANCE")
1003        );
1004        assert_eq!(depths[0].bids.len(), 5);
1005        assert_eq!(depths[0].bids[0].price, Price::from("11657.07"));
1006        assert_eq!(depths[0].bids[0].size, Quantity::from("10.896"));
1007        assert_eq!(depths[0].bids[0].side, OrderSide::Buy.into());
1008        assert_eq!(depths[0].bids[0].order_id, 0);
1009        assert_eq!(depths[0].asks.len(), 5);
1010        assert_eq!(depths[0].asks[0].price, Price::from("11657.08"));
1011        assert_eq!(depths[0].asks[0].size, Quantity::from("1.714"));
1012        assert_eq!(depths[0].asks[0].side, OrderSide::Sell.into());
1013        assert_eq!(depths[0].asks[0].order_id, 0);
1014        assert_eq!(depths[0].bid_counts.as_slice(), &[1; 5]);
1015        assert_eq!(depths[0].ask_counts.as_slice(), &[1; 5]);
1016        // F_SNAPSHOT (32) | F_LAST (128) = 160
1017        assert_eq!(
1018            depths[0].flags,
1019            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1020        );
1021        assert_eq!(depths[0].ts_event, 1598918403696000000);
1022        assert_eq!(depths[0].ts_init, 1598918403810979000);
1023        assert_eq!(depths[0].sequence, 0);
1024    }
1025
1026    #[rstest]
1027    #[case(Some(2), Some(3))] // Explicit precisions
1028    #[case(None, None)] // Inferred precisions
1029    pub fn test_read_depths_from_snapshot25(
1030        #[case] price_precision: Option<u8>,
1031        #[case] size_precision: Option<u8>,
1032    ) {
1033        let filepath = get_tardis_binance_snapshot25_path();
1034        let depths =
1035            load_depth_from_snapshot25(filepath, price_precision, size_precision, None, Some(100))
1036                .unwrap();
1037
1038        assert_eq!(depths.len(), 10);
1039        assert_eq!(
1040            depths[0].instrument_id,
1041            InstrumentId::from("BTCUSDT.BINANCE")
1042        );
1043        assert_eq!(depths[0].bids.len(), 25);
1044        assert_eq!(depths[0].bids[0].price, Price::from("11657.07"));
1045        assert_eq!(depths[0].bids[0].size, Quantity::from("10.896"));
1046        assert_eq!(depths[0].bids[0].side, OrderSide::Buy.into());
1047        assert_eq!(depths[0].bids[0].order_id, 0);
1048        assert_eq!(depths[0].asks.len(), 25);
1049        assert_eq!(depths[0].asks[0].price, Price::from("11657.08"));
1050        assert_eq!(depths[0].asks[0].size, Quantity::from("1.714"));
1051        assert_eq!(depths[0].asks[0].side, OrderSide::Sell.into());
1052        assert_eq!(depths[0].asks[0].order_id, 0);
1053        assert_eq!(depths[0].bid_counts[0], 1);
1054        assert_eq!(depths[0].ask_counts[0], 1);
1055        // F_SNAPSHOT (32) | F_LAST (128) = 160
1056        assert_eq!(
1057            depths[0].flags,
1058            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1059        );
1060        assert_eq!(depths[0].ts_event, 1598918403696000000);
1061        assert_eq!(depths[0].ts_init, 1598918403810979000);
1062        assert_eq!(depths[0].sequence, 0);
1063    }
1064
1065    #[rstest]
1066    #[case(Some(1), Some(0))] // Explicit precisions
1067    #[case(None, None)] // Inferred precisions
1068    pub fn test_read_quotes(
1069        #[case] price_precision: Option<u8>,
1070        #[case] size_precision: Option<u8>,
1071    ) {
1072        let filepath = get_tardis_huobi_quotes_path();
1073        let quotes =
1074            load_quotes(filepath, price_precision, size_precision, None, Some(100)).unwrap();
1075
1076        assert_eq!(quotes.len(), 10);
1077        assert_eq!(
1078            quotes[0].instrument_id,
1079            InstrumentId::from("BTC-USD.HUOBI_DELIVERY")
1080        );
1081        assert_eq!(quotes[0].bid_price, Price::from("8629.2"));
1082        assert_eq!(quotes[0].bid_size, Quantity::from("806"));
1083        assert_eq!(quotes[0].ask_price, Price::from("8629.3"));
1084        assert_eq!(quotes[0].ask_size, Quantity::from("5494"));
1085        assert_eq!(quotes[0].ts_event, 1588291201099000000);
1086        assert_eq!(quotes[0].ts_init, 1588291201234268000);
1087    }
1088
1089    #[rstest]
1090    fn test_load_options_chain_filters_underlying_and_emits_quote_then_greeks() {
1091        let filepath = get_test_data_path("options_chain.csv");
1092        let data =
1093            load_options_chain(filepath, Some(vec!["btc-".to_string()]), None, None, None).unwrap();
1094
1095        assert_eq!(data.len(), 9);
1096
1097        let Data::Quote(quote) = &data[0] else {
1098            panic!("Expected first data item to be Quote");
1099        };
1100        let Data::OptionGreeks(greeks) = &data[1] else {
1101            panic!("Expected second data item to be OptionGreeks");
1102        };
1103
1104        assert_eq!(
1105            quote.instrument_id,
1106            InstrumentId::from("BTC-9JUN20-9875-P.DERIBIT")
1107        );
1108        assert_eq!(quote.bid_price, Price::from("0.0205"));
1109        assert_eq!(quote.ask_price, Price::from("0.0235"));
1110        assert_eq!(quote.bid_size, Quantity::from("15.1"));
1111        assert_eq!(quote.ask_size, Quantity::from("15.2"));
1112        assert_eq!(quote.bid_price.precision, 4);
1113        assert_eq!(quote.bid_size.precision, 1);
1114
1115        assert_eq!(greeks.instrument_id, quote.instrument_id);
1116        assert_eq!(greeks.greeks.delta, -0.61752);
1117        assert_eq!(greeks.mark_iv, Some(62.89));
1118        assert_eq!(greeks.underlying_price, Some(9756.36));
1119    }
1120
1121    #[rstest]
1122    fn test_load_options_chain_missing_bbo_emits_greeks_only_with_default_greeks() {
1123        let filepath = get_test_data_path("options_chain.csv");
1124        let data = load_options_chain(
1125            filepath,
1126            Some(vec!["BTC-10JUN20".to_string()]),
1127            None,
1128            None,
1129            None,
1130        )
1131        .unwrap();
1132
1133        assert_eq!(data.len(), 1);
1134
1135        let Data::OptionGreeks(greeks) = &data[0] else {
1136            panic!("Expected OptionGreeks, was {:?}", data[0]);
1137        };
1138
1139        assert_eq!(
1140            greeks.instrument_id,
1141            InstrumentId::from("BTC-10JUN20-10000-C.DERIBIT")
1142        );
1143        assert_eq!(greeks.open_interest, None);
1144        assert_eq!(greeks.bid_iv, None);
1145        assert_eq!(greeks.ask_iv, None);
1146        assert_eq!(greeks.greeks.delta, 0.0);
1147        assert_eq!(greeks.greeks.gamma, 0.0);
1148        assert_eq!(greeks.greeks.vega, 0.0);
1149        assert_eq!(greeks.greeks.theta, 0.0);
1150        assert_eq!(greeks.greeks.rho, 0.0);
1151    }
1152
1153    #[rstest]
1154    fn test_load_options_chain_rejects_zero_bbo_size() {
1155        let temp_file = tempfile::NamedTempFile::new().unwrap();
1156        let csv_data = "exchange,symbol,timestamp,local_timestamp,type,strike_price,expiration,open_interest,last_price,bid_price,bid_amount,bid_iv,ask_price,ask_amount,ask_iv,mark_price,mark_iv,underlying_index,underlying_price,delta,gamma,vega,theta,rho
1157deribit,BTC-9JUN20-9875-P,1591574399413000,1591574400196008,put,9875,1591689600000000,0.1,0.0295,0.0205,0,55.91,0.0235,15.2,68.94,0.02210436,62.89,SYN.BTC-9JUN20,9756.36,-0.61752,0.00103,2.24964,-53.05655,-0.22796";
1158        fs::write(temp_file.path(), csv_data).unwrap();
1159
1160        let error = load_options_chain(temp_file.path(), None, None, None, None).unwrap_err();
1161
1162        assert_eq!(error.to_string(), "value was zero");
1163    }
1164
1165    #[rstest]
1166    fn test_load_options_chain_infers_precision_per_instrument() {
1167        let filepath = get_test_data_path("options_chain.csv");
1168        let data =
1169            load_options_chain(filepath, Some(vec!["ETH-".to_string()]), None, None, None).unwrap();
1170
1171        assert_eq!(data.len(), 3);
1172
1173        let Data::Quote(quote) = &data[0] else {
1174            panic!("Expected first data item to be Quote");
1175        };
1176
1177        assert_eq!(
1178            quote.instrument_id,
1179            InstrumentId::from("ETH-9JUN20-250-P.DERIBIT")
1180        );
1181        assert_eq!(quote.bid_price, Price::from("0.12345"));
1182        assert_eq!(quote.ask_price, Price::from("0.12456"));
1183        assert_eq!(quote.bid_size, Quantity::from("0.123456"));
1184        assert_eq!(quote.ask_size, Quantity::from("0.223456"));
1185        assert_eq!(quote.bid_price.precision, 5);
1186        assert_eq!(quote.bid_size.precision, 6);
1187
1188        assert!(matches!(data[1], Data::OptionGreeks(_)));
1189        assert!(matches!(data[2], Data::OptionGreeks(_)));
1190    }
1191
1192    #[rstest]
1193    #[case(Some(1), Some(0))] // Explicit precisions
1194    #[case(None, None)] // Inferred precisions
1195    pub fn test_read_trades(
1196        #[case] price_precision: Option<u8>,
1197        #[case] size_precision: Option<u8>,
1198    ) {
1199        let filepath = get_tardis_bitmex_trades_path();
1200        let trades =
1201            load_trades(filepath, price_precision, size_precision, None, Some(100)).unwrap();
1202
1203        assert_eq!(trades.len(), 10);
1204        assert_eq!(trades[0].instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1205        assert_eq!(trades[0].price, Price::from("8531.5"));
1206        assert_eq!(trades[0].size, Quantity::from("2152"));
1207        assert_eq!(trades[0].aggressor_side, AggressorSide::Sell);
1208        assert_eq!(
1209            trades[0].trade_id,
1210            TradeId::new("ccc3c1fa-212c-e8b0-1706-9b9c4f3d5ecf")
1211        );
1212        assert_eq!(trades[0].ts_event, 1583020803145000000);
1213        assert_eq!(trades[0].ts_init, 1583020803307160000);
1214    }
1215
1216    #[rstest]
1217    pub fn test_load_trades_derives_id_when_csv_id_empty() {
1218        // Two rows with empty `id` column must both hash deterministically
1219        // to the same TradeId, and a row with differing price must hash differently.
1220        let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
1221binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50000.0,1.0
1222binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50000.0,1.0
1223binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50001.0,1.0";
1224
1225        let temp_file = std::env::temp_dir().join("test_load_trades_empty_id.csv");
1226        std::fs::write(&temp_file, csv_data).unwrap();
1227
1228        let trades = load_trades(&temp_file, Some(2), Some(1), None, None).unwrap();
1229        assert_eq!(trades.len(), 3);
1230
1231        assert_eq!(trades[0].trade_id, trades[1].trade_id);
1232        assert_eq!(trades[0].trade_id.as_str().len(), 16);
1233        assert_ne!(trades[0].trade_id, trades[2].trade_id);
1234
1235        std::fs::remove_file(&temp_file).ok();
1236    }
1237
1238    #[rstest]
1239    pub fn test_load_trades_with_zero_sized_trade() {
1240        // Create test CSV data with one zero-sized trade that should be skipped
1241        let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
1242binance,BTCUSDT,1640995200000000,1640995200100000,trade1,buy,50000.0,1.0
1243binance,BTCUSDT,1640995201000000,1640995201100000,trade2,sell,49999.5,0.0
1244binance,BTCUSDT,1640995202000000,1640995202100000,trade3,buy,50000.12,1.5
1245binance,BTCUSDT,1640995203000000,1640995203100000,trade4,sell,49999.123,3.0";
1246
1247        let temp_file = std::env::temp_dir().join("test_load_trades_zero_size.csv");
1248        std::fs::write(&temp_file, csv_data).unwrap();
1249
1250        let trades = load_trades(
1251            &temp_file,
1252            Some(4),
1253            Some(1),
1254            None,
1255            None, // No limit, load all
1256        )
1257        .unwrap();
1258
1259        // Should have 3 trades (zero-sized trade skipped)
1260        assert_eq!(trades.len(), 3);
1261
1262        // Verify the correct trades were loaded (not the zero-sized one)
1263        assert_eq!(trades[0].size, Quantity::from("1.0"));
1264        assert_eq!(trades[1].size, Quantity::from("1.5"));
1265        assert_eq!(trades[2].size, Quantity::from("3.0"));
1266
1267        // Verify trade IDs to confirm correct trades were loaded
1268        assert_eq!(trades[0].trade_id, TradeId::new("trade1"));
1269        assert_eq!(trades[1].trade_id, TradeId::new("trade3"));
1270        assert_eq!(trades[2].trade_id, TradeId::new("trade4"));
1271
1272        std::fs::remove_file(&temp_file).ok();
1273    }
1274
1275    #[rstest]
1276    pub fn test_load_trades_from_local_file() {
1277        let filepath = get_test_data_path("csv/trades_1.csv");
1278        let trades = load_trades(filepath, Some(1), Some(0), None, None).unwrap();
1279        assert_eq!(trades.len(), 2);
1280        assert_eq!(trades[0].price, Price::from("8531.5"));
1281        assert_eq!(trades[1].size, Quantity::from("1000"));
1282    }
1283
1284    #[rstest]
1285    pub fn test_load_deltas_from_local_file() {
1286        let filepath = get_test_data_path("csv/deltas_1.csv");
1287        let deltas = load_deltas(filepath, Some(1), Some(0), None, None).unwrap();
1288
1289        // 2 data rows + 1 CLEAR delta at start (first row is snapshot)
1290        assert_eq!(deltas.len(), 3);
1291        assert_eq!(deltas[0].action, BookAction::Clear);
1292        assert_eq!(deltas[1].order.price, Price::from("6421.5"));
1293        assert_eq!(deltas[2].order.size, Quantity::from("10000"));
1294    }
1295
1296    #[rstest]
1297    fn test_load_deltas_skips_rows_before_first_snapshot() {
1298        // Two leading rows are pre-snapshot orphans and must be skipped, see
1299        // https://docs.tardis.dev/faq/order-books
1300        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1301binance-futures,BTCUSDT,1,1,false,bid,99.0,1.0
1302binance-futures,BTCUSDT,2,2,false,ask,101.0,2.0
1303binance-futures,BTCUSDT,3,3,true,bid,100.0,5.0
1304binance-futures,BTCUSDT,3,3,true,ask,100.5,6.0
1305binance-futures,BTCUSDT,4,4,false,bid,100.0,7.0";
1306
1307        let temp_file = std::env::temp_dir().join("test_load_deltas_pre_snapshot_orphans.csv");
1308        std::fs::write(&temp_file, csv_data).unwrap();
1309
1310        let deltas = load_deltas(&temp_file, Some(1), Some(0), None, None).unwrap();
1311
1312        // The 2 pre-snapshot rows are skipped entirely: 1 CLEAR + 2 snapshot Adds + 1 Update.
1313        assert_eq!(deltas.len(), 4);
1314        assert_eq!(deltas[0].action, BookAction::Clear);
1315        assert_eq!(deltas[0].ts_event, UnixNanos::from(3_000));
1316        assert_eq!(deltas[0].ts_init, UnixNanos::from(3_000));
1317        assert_eq!(deltas[1].action, BookAction::Add);
1318        assert_eq!(deltas[1].order.price, Price::from("100.0"));
1319        assert_eq!(deltas[2].action, BookAction::Add);
1320        assert_eq!(deltas[2].order.price, Price::from("100.5"));
1321        assert_eq!(deltas[3].action, BookAction::Update);
1322        assert_eq!(deltas[3].order.price, Price::from("100.0"));
1323
1324        std::fs::remove_file(&temp_file).ok();
1325    }
1326
1327    #[rstest]
1328    fn test_load_deltas_returns_empty_when_no_snapshot_present() {
1329        // A file with no `is_snapshot=true` row anywhere has no usable base state to apply
1330        // deltas against, so every row is skipped and no deltas are produced.
1331        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1332binance-futures,BTCUSDT,1,1,false,bid,99.0,1.0
1333binance-futures,BTCUSDT,2,2,false,ask,101.0,2.0";
1334
1335        let temp_file = std::env::temp_dir().join("test_load_deltas_no_snapshot.csv");
1336        std::fs::write(&temp_file, csv_data).unwrap();
1337
1338        let deltas = load_deltas(&temp_file, Some(1), Some(0), None, None).unwrap();
1339
1340        assert!(deltas.is_empty());
1341
1342        std::fs::remove_file(&temp_file).ok();
1343    }
1344
1345    #[rstest]
1346    fn test_load_deltas_groups_messages_by_local_timestamp() {
1347        // Fixture opens with a snapshot row (own, earlier local_timestamp) so the two message
1348        // groups that follow are not treated as pre-snapshot orphans; it contributes its own
1349        // CLEAR + Add pair ahead of the two original message groups.
1350        let filepath = get_test_data_path("csv/deltas_message_boundaries.csv");
1351        let deltas = load_deltas(filepath, Some(1), Some(1), None, None).unwrap();
1352
1353        assert_eq!(deltas.len(), 6);
1354        assert_eq!(deltas[0].action, BookAction::Clear);
1355        assert_eq!(
1356            deltas.iter().map(|delta| delta.flags).collect::<Vec<_>>(),
1357            vec![
1358                RecordFlag::F_SNAPSHOT as u8, // OrderBookDelta::clear() always sets F_SNAPSHOT
1359                RecordFlag::F_LAST as u8,
1360                0,
1361                RecordFlag::F_LAST as u8,
1362                0,
1363                RecordFlag::F_LAST as u8,
1364            ]
1365        );
1366        assert_eq!(
1367            deltas
1368                .iter()
1369                .map(|delta| delta.ts_event)
1370                .collect::<Vec<_>>(),
1371            vec![
1372                UnixNanos::from(900_000),
1373                UnixNanos::from(900_000),
1374                UnixNanos::from(1_000_000),
1375                UnixNanos::from(1_000_000),
1376                UnixNanos::from(1_000_000),
1377                UnixNanos::from(1_010_000),
1378            ]
1379        );
1380        assert_eq!(
1381            deltas.iter().map(|delta| delta.ts_init).collect::<Vec<_>>(),
1382            vec![
1383                UnixNanos::from(1_900_000),
1384                UnixNanos::from(1_900_000),
1385                UnixNanos::from(2_000_000),
1386                UnixNanos::from(2_000_000),
1387                UnixNanos::from(2_010_000),
1388                UnixNanos::from(2_010_000),
1389            ]
1390        );
1391        assert_eq!(deltas[1].order.side, Some(OrderSide::Buy));
1392        assert_eq!(deltas[1].order.price, Price::from("98.0"));
1393        assert_eq!(deltas[1].order.size, Quantity::from("9.0"));
1394        assert_eq!(deltas[2].order.side, Some(OrderSide::Buy));
1395        assert_eq!(deltas[2].order.price, Price::from("100.0"));
1396        assert_eq!(deltas[2].order.size, Quantity::from("1.0"));
1397        assert_eq!(deltas[3].order.side, Some(OrderSide::Sell));
1398        assert_eq!(deltas[3].order.price, Price::from("101.0"));
1399        assert_eq!(deltas[3].order.size, Quantity::from("2.0"));
1400        assert_eq!(deltas[4].order.side, Some(OrderSide::Buy));
1401        assert_eq!(deltas[4].order.price, Price::from("99.0"));
1402        assert_eq!(deltas[4].order.size, Quantity::from("3.0"));
1403        assert_eq!(deltas[5].order.side, Some(OrderSide::Sell));
1404        assert_eq!(deltas[5].order.price, Price::from("102.0"));
1405        assert_eq!(deltas[5].order.size, Quantity::from("4.0"));
1406    }
1407
1408    #[rstest]
1409    fn test_load_funding_rates_okex_xperp() {
1410        let filepath = get_test_data_path("csv/okex_futures_xperp_derivative_ticker.csv");
1411        let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1412
1413        let instrument_id = InstrumentId::from("BTC-USD_UM_XPERP-310404.OKEX");
1414
1415        assert_eq!(funding_rates.len(), 8);
1416        assert!(
1417            funding_rates
1418                .iter()
1419                .all(|f| f.instrument_id == instrument_id)
1420        );
1421
1422        // OKX X-Perps publish no predicted rate, so the funding timestamp is the only forward
1423        // reference Tardis carries, and the interval is not representable in a derivative ticker
1424        assert!(
1425            funding_rates
1426                .iter()
1427                .all(|f| f.next_funding_ns.is_some() && f.interval.is_none())
1428        );
1429
1430        let first = &funding_rates[0];
1431        let rolled = &funding_rates[3];
1432
1433        assert_eq!(first.rate, dec!(-0.0003972900658902));
1434        assert_eq!(
1435            first.next_funding_ns,
1436            Some(UnixNanos::from(1_786_320_000_000_000_000))
1437        );
1438        assert_eq!(first.ts_event, UnixNanos::from(1_786_320_006_952_000_000));
1439        assert_eq!(first.ts_init, UnixNanos::from(1_786_320_006_971_532_000));
1440
1441        assert_eq!(rolled.rate, dec!(-0.0003962534258591));
1442        assert_eq!(
1443            rolled.next_funding_ns,
1444            Some(UnixNanos::from(1_786_348_800_000_000_000))
1445        );
1446        assert_eq!(rolled.ts_event, UnixNanos::from(1_786_320_007_369_000_000));
1447        assert_eq!(rolled.ts_init, UnixNanos::from(1_786_320_007_402_423_000));
1448    }
1449
1450    #[rstest]
1451    fn test_load_funding_rates_without_funding_timestamp() {
1452        let filepath = get_test_data_path("csv/deribit_derivative_ticker.csv");
1453        let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1454
1455        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1456
1457        assert_eq!(funding_rates.len(), 3);
1458        assert!(
1459            funding_rates
1460                .iter()
1461                .all(|f| f.instrument_id == instrument_id)
1462        );
1463
1464        // Deribit publishes no funding timestamp, so there is no forward reference to carry
1465        assert!(
1466            funding_rates
1467                .iter()
1468                .all(|f| f.next_funding_ns.is_none() && f.interval.is_none())
1469        );
1470
1471        let first = &funding_rates[0];
1472        let changed = &funding_rates[2];
1473
1474        assert_eq!(first.rate, dec!(0.00000459));
1475        assert_eq!(first.ts_event, UnixNanos::from(1_786_320_665_523_000_000));
1476        assert_eq!(first.ts_init, UnixNanos::from(1_786_320_665_533_324_000));
1477
1478        assert_eq!(changed.rate, dec!(0.00000452));
1479        assert_eq!(changed.ts_event, UnixNanos::from(1_786_320_665_645_000_000));
1480        assert_eq!(changed.ts_init, UnixNanos::from(1_786_320_665_661_106_000));
1481    }
1482
1483    #[rstest]
1484    fn test_load_funding_rates_okex_usdc_across_index_migration() {
1485        let filepath = get_test_data_path("csv/okex_swap_usdc_index_migration.csv");
1486        let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1487
1488        let instrument_id = InstrumentId::from("BTC-USDC-SWAP.OKEX");
1489
1490        assert_eq!(funding_rates.len(), 6);
1491
1492        // Tardis remaps USDC-margined contracts to the USDC index after 2023-04-10T08:40Z, which
1493        // changes the index price feed but never the contract symbol, so replaying across the
1494        // migration must resolve to one instrument
1495        assert!(
1496            funding_rates
1497                .iter()
1498                .all(|f| f.instrument_id == instrument_id)
1499        );
1500
1501        let pre = &funding_rates[0];
1502        let post = &funding_rates[3];
1503
1504        assert_eq!(pre.rate, dec!(0.0001035718476117));
1505        assert_eq!(
1506            pre.next_funding_ns,
1507            Some(UnixNanos::from(1_680_336_000_000_000_000))
1508        );
1509        assert_eq!(pre.ts_event, UnixNanos::from(1_680_309_048_427_000_000));
1510        assert_eq!(pre.ts_init, UnixNanos::from(1_680_309_048_450_728_000));
1511
1512        assert_eq!(post.rate, dec!(-0.000055804472025));
1513        assert_eq!(
1514            post.next_funding_ns,
1515            Some(UnixNanos::from(1_682_928_000_000_000_000))
1516        );
1517        assert_eq!(post.ts_event, UnixNanos::from(1_682_900_658_676_000_000));
1518        assert_eq!(post.ts_init, UnixNanos::from(1_682_900_658_698_855_000));
1519    }
1520
1521    #[rstest]
1522    fn test_load_depth_from_snapshot5_comprehensive() {
1523        let filepath = get_tardis_binance_snapshot5_path();
1524        let depths = load_depth_from_snapshot5(&filepath, None, None, None, Some(100)).unwrap();
1525
1526        assert_eq!(depths.len(), 10);
1527
1528        let first = &depths[0];
1529        assert_eq!(first.instrument_id.to_string(), "BTCUSDT.BINANCE");
1530        assert_eq!(first.bids.len(), 5);
1531        assert_eq!(first.asks.len(), 5);
1532
1533        // Check all bid levels (5 from data)
1534        assert_eq!(first.bids[0].price, Price::from("11657.07"));
1535        assert_eq!(first.bids[0].size, Quantity::from("10.896"));
1536        assert_eq!(first.bids[0].side, OrderSide::Buy.into());
1537
1538        assert_eq!(first.bids[1].price, Price::from("11656.97"));
1539        assert_eq!(first.bids[1].size, Quantity::from("0.2"));
1540        assert_eq!(first.bids[1].side, OrderSide::Buy.into());
1541
1542        assert_eq!(first.bids[2].price, Price::from("11655.78"));
1543        assert_eq!(first.bids[2].size, Quantity::from("0.2"));
1544        assert_eq!(first.bids[2].side, OrderSide::Buy.into());
1545
1546        assert_eq!(first.bids[3].price, Price::from("11655.77"));
1547        assert_eq!(first.bids[3].size, Quantity::from("0.98"));
1548        assert_eq!(first.bids[3].side, OrderSide::Buy.into());
1549
1550        assert_eq!(first.bids[4].price, Price::from("11655.68"));
1551        assert_eq!(first.bids[4].size, Quantity::from("0.111"));
1552        assert_eq!(first.bids[4].side, OrderSide::Buy.into());
1553
1554        // Check all ask levels (5 from data)
1555        assert_eq!(first.asks[0].price, Price::from("11657.08"));
1556        assert_eq!(first.asks[0].size, Quantity::from("1.714"));
1557        assert_eq!(first.asks[0].side, OrderSide::Sell.into());
1558
1559        assert_eq!(first.asks[1].price, Price::from("11657.54"));
1560        assert_eq!(first.asks[1].size, Quantity::from("5.4"));
1561        assert_eq!(first.asks[1].side, OrderSide::Sell.into());
1562
1563        assert_eq!(first.asks[2].price, Price::from("11657.56"));
1564        assert_eq!(first.asks[2].size, Quantity::from("0.238"));
1565        assert_eq!(first.asks[2].side, OrderSide::Sell.into());
1566
1567        assert_eq!(first.asks[3].price, Price::from("11657.61"));
1568        assert_eq!(first.asks[3].size, Quantity::from("0.077"));
1569        assert_eq!(first.asks[3].side, OrderSide::Sell.into());
1570
1571        assert_eq!(first.asks[4].price, Price::from("11657.92"));
1572        assert_eq!(first.asks[4].size, Quantity::from("0.918"));
1573        assert_eq!(first.asks[4].side, OrderSide::Sell.into());
1574
1575        // Logical checks: bid prices should decrease
1576        for i in 1..5 {
1577            assert!(
1578                first.bids[i].price < first.bids[i - 1].price,
1579                "Bid price at level {} should be less than level {}",
1580                i,
1581                i - 1
1582            );
1583        }
1584
1585        // Logical checks: ask prices should increase
1586        for i in 1..5 {
1587            assert!(
1588                first.asks[i].price > first.asks[i - 1].price,
1589                "Ask price at level {} should be greater than level {}",
1590                i,
1591                i - 1
1592            );
1593        }
1594
1595        // Logical check: spread should be positive
1596        assert!(
1597            first.asks[0].price > first.bids[0].price,
1598            "Best ask should be greater than best bid"
1599        );
1600
1601        assert_eq!(first.bid_counts.as_slice(), &[1; 5]);
1602        assert_eq!(first.ask_counts.as_slice(), &[1; 5]);
1603        for order in first.bids.iter().chain(&first.asks) {
1604            assert_eq!(order.order_id, 0);
1605        }
1606
1607        // Check metadata - F_SNAPSHOT (32) | F_LAST (128) = 160
1608        assert_eq!(
1609            first.flags,
1610            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1611        );
1612        assert_eq!(first.ts_event.as_u64(), 1598918403696000000);
1613        assert_eq!(first.ts_init.as_u64(), 1598918403810979000);
1614        assert_eq!(first.sequence, 0);
1615    }
1616
1617    #[rstest]
1618    fn test_load_depth_from_snapshot25_comprehensive() {
1619        let filepath = get_tardis_binance_snapshot25_path();
1620        let depths = load_depth_from_snapshot25(&filepath, None, None, None, Some(100)).unwrap();
1621
1622        assert_eq!(depths.len(), 10);
1623
1624        let first = &depths[0];
1625        assert_eq!(first.instrument_id.to_string(), "BTCUSDT.BINANCE");
1626        assert_eq!(first.bids.len(), 25);
1627        assert_eq!(first.asks.len(), 25);
1628
1629        // Check all 25 bid levels from snapshot25
1630        let expected_bids = vec![
1631            ("11657.07", "10.896"),
1632            ("11656.97", "0.2"),
1633            ("11655.78", "0.2"),
1634            ("11655.77", "0.98"),
1635            ("11655.68", "0.111"),
1636            ("11655.66", "0.077"),
1637            ("11655.57", "0.34"),
1638            ("11655.48", "0.4"),
1639            ("11655.26", "1.185"),
1640            ("11654.86", "0.195"),
1641            ("11654.85", "0.275"),
1642            ("11654.7", "0.175"),
1643            ("11654.69", "0.194"),
1644            ("11654.67", "1"),
1645            ("11654.65", "0.05"),
1646            ("11654.58", "0.05"),
1647            ("11654.41", "0.11"),
1648            ("11654.28", "0.618"),
1649            ("11653.84", "0.135"),
1650            ("11653.4", "0.17"),
1651            ("11653.39", "1.008"),
1652            ("11653.35", "4"),
1653            ("11653.34", "2"),
1654            ("11653.32", "0.5"),
1655            ("11653.25", "1.003"),
1656        ];
1657
1658        for (i, (price, size)) in expected_bids.iter().enumerate() {
1659            assert_eq!(first.bids[i].price, Price::from(*price));
1660            assert_eq!(first.bids[i].size, Quantity::from(*size));
1661            assert_eq!(first.bids[i].side, OrderSide::Buy.into());
1662        }
1663
1664        // Check all 25 ask levels from snapshot25
1665        let expected_asks = vec![
1666            ("11657.08", "1.714"),
1667            ("11657.54", "5.4"),
1668            ("11657.56", "0.238"),
1669            ("11657.61", "0.077"),
1670            ("11657.92", "0.918"),
1671            ("11658.09", "1.015"),
1672            ("11658.12", "0.665"),
1673            ("11658.19", "0.583"),
1674            ("11658.28", "0.255"),
1675            ("11658.29", "0.656"),
1676            ("11658.64", "1.463"),
1677            ("11658.71", "0.155"),
1678            ("11658.75", "0.155"),
1679            ("11658.88", "0.625"),
1680            ("11658.94", "0.155"),
1681            ("11658.98", "1.005"),
1682            ("11658.99", "0.155"),
1683            ("11659", "1.922"),
1684            ("11659.02", "0.001"),
1685            ("11659.13", "0.11"),
1686            ("11659.17", "0.144"),
1687            ("11659.18", "0.665"),
1688            ("11659.22", "0.22"),
1689            ("11659.28", "0.06"),
1690            ("11659.34", "0.618"),
1691        ];
1692
1693        for (i, (price, size)) in expected_asks.iter().enumerate() {
1694            assert_eq!(first.asks[i].price, Price::from(*price));
1695            assert_eq!(first.asks[i].size, Quantity::from(*size));
1696            assert_eq!(first.asks[i].side, OrderSide::Sell.into());
1697        }
1698
1699        // Logical checks: bid prices should strictly decrease
1700        for i in 1..25 {
1701            assert!(
1702                first.bids[i].price < first.bids[i - 1].price,
1703                "Bid price at level {} ({}) should be less than level {} ({})",
1704                i,
1705                first.bids[i].price,
1706                i - 1,
1707                first.bids[i - 1].price
1708            );
1709        }
1710
1711        // Logical checks: ask prices should strictly increase
1712        for i in 1..25 {
1713            assert!(
1714                first.asks[i].price > first.asks[i - 1].price,
1715                "Ask price at level {} ({}) should be greater than level {} ({})",
1716                i,
1717                first.asks[i].price,
1718                i - 1,
1719                first.asks[i - 1].price
1720            );
1721        }
1722
1723        // Logical check: spread should be positive
1724        assert!(
1725            first.asks[0].price > first.bids[0].price,
1726            "Best ask ({}) should be greater than best bid ({})",
1727            first.asks[0].price,
1728            first.bids[0].price
1729        );
1730
1731        // Check counts (all should be 1 for snapshot data)
1732        for i in 0..25 {
1733            assert_eq!(first.bid_counts[i], 1);
1734            assert_eq!(first.ask_counts[i], 1);
1735        }
1736
1737        // Check metadata - F_SNAPSHOT (32) | F_LAST (128) = 160
1738        assert_eq!(
1739            first.flags,
1740            RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1741        );
1742        assert_eq!(first.ts_event.as_u64(), 1598918403696000000);
1743        assert_eq!(first.ts_init.as_u64(), 1598918403810979000);
1744        assert_eq!(first.sequence, 0);
1745    }
1746
1747    /// Writes a two-row depth snapshot CSV with `levels` levels per side where the
1748    /// second row's best bid price carries higher precision, forcing a retroactive
1749    /// precision rewrite of the first row's depth.
1750    fn write_precision_rewrite_csv(path: &std::path::Path, levels: usize) {
1751        let mut headers = vec![
1752            "exchange".to_string(),
1753            "symbol".to_string(),
1754            "timestamp".to_string(),
1755            "local_timestamp".to_string(),
1756        ];
1757
1758        for i in 0..levels {
1759            headers.extend([
1760                format!("asks[{i}].price"),
1761                format!("asks[{i}].amount"),
1762                format!("bids[{i}].price"),
1763                format!("bids[{i}].amount"),
1764            ]);
1765        }
1766
1767        let row = |timestamp: &str, best_bid: &str| -> String {
1768            let mut fields = vec![
1769                "binance-futures".to_string(),
1770                "BTCUSDT".to_string(),
1771                timestamp.to_string(),
1772                timestamp.to_string(),
1773            ];
1774
1775            for i in 0..levels {
1776                let bid = if i == 0 {
1777                    best_bid.to_string()
1778                } else {
1779                    format!("{}", 49_990 - i)
1780                };
1781                fields.extend([
1782                    format!("{}", 50_001 + i),
1783                    "1.5".to_string(),
1784                    bid,
1785                    "1.5".to_string(),
1786                ]);
1787            }
1788            fields.join(",")
1789        };
1790
1791        let csv_data = format!(
1792            "{}\n{}\n{}",
1793            headers.join(","),
1794            row("1640995200000000", "49999"),
1795            row("1640995201000000", "49998.12"),
1796        );
1797        std::fs::write(path, csv_data).unwrap();
1798    }
1799
1800    #[rstest]
1801    fn test_load_depth_from_snapshot25_precision_rewrite_updates_all_levels() {
1802        let temp_file = std::env::temp_dir().join("test_depth_snapshot25_precision_rewrite.csv");
1803        write_precision_rewrite_csv(&temp_file, 25);
1804
1805        let depths = load_depth_from_snapshot25(&temp_file, None, None, None, None).unwrap();
1806        assert_eq!(depths.len(), 2);
1807
1808        // Both rows end at the maximum inferred precision on every level,
1809        // including levels past the first 10
1810        for (r, depth) in depths.iter().enumerate() {
1811            assert_eq!(depth.bids.len(), 25, "row {r}");
1812            assert_eq!(depth.asks.len(), 25, "row {r}");
1813            for (i, order) in depth.bids.iter().chain(depth.asks.iter()).enumerate() {
1814                assert_eq!(order.price.precision, 2, "row {r} level {i}");
1815                assert_eq!(order.size.precision, 1, "row {r} level {i}");
1816            }
1817        }
1818        // Values are unchanged; only the precision display is rewritten
1819        assert_eq!(depths[0].bids[0].price, Price::new(49999.0, 2));
1820        assert_eq!(depths[0].bids[24].price, Price::new(49966.0, 2));
1821        assert_eq!(depths[1].bids[0].price, Price::new(49998.12, 2));
1822
1823        std::fs::remove_file(&temp_file).ok();
1824    }
1825
1826    #[rstest]
1827    fn test_load_depth_from_snapshot5_precision_rewrite_updates_all_levels() {
1828        let temp_file = std::env::temp_dir().join("test_depth_snapshot5_precision_rewrite.csv");
1829        write_precision_rewrite_csv(&temp_file, 5);
1830
1831        let depths = load_depth_from_snapshot5(&temp_file, None, None, None, None).unwrap();
1832        assert_eq!(depths.len(), 2);
1833
1834        // The rewrite must cover the 5 retained levels without indexing past them
1835        for (r, depth) in depths.iter().enumerate() {
1836            assert_eq!(depth.bids.len(), 5, "row {r}");
1837            assert_eq!(depth.asks.len(), 5, "row {r}");
1838            for (i, order) in depth.bids.iter().chain(depth.asks.iter()).enumerate() {
1839                assert_eq!(order.price.precision, 2, "row {r} level {i}");
1840                assert_eq!(order.size.precision, 1, "row {r} level {i}");
1841            }
1842        }
1843        assert_eq!(depths[0].bids[0].price, Price::new(49999.0, 2));
1844        assert_eq!(depths[1].bids[0].price, Price::new(49998.12, 2));
1845
1846        std::fs::remove_file(&temp_file).ok();
1847    }
1848
1849    #[rstest]
1850    fn test_snapshot_csv_field_order_interleaved() {
1851        // This test verifies that the CSV structs correctly handle the interleaved
1852        // asks/bids field ordering from Tardis CSV files
1853
1854        let csv_data = "exchange,symbol,timestamp,local_timestamp,\
1855asks[0].price,asks[0].amount,bids[0].price,bids[0].amount,\
1856asks[1].price,asks[1].amount,bids[1].price,bids[1].amount,\
1857asks[2].price,asks[2].amount,bids[2].price,bids[2].amount,\
1858asks[3].price,asks[3].amount,bids[3].price,bids[3].amount,\
1859asks[4].price,asks[4].amount,bids[4].price,bids[4].amount
1860binance-futures,BTCUSDT,1000000,2000000,\
1861100.5,1.0,100.4,2.0,\
1862100.6,1.1,100.3,2.1,\
1863100.7,1.2,100.2,2.2,\
1864100.8,1.3,100.1,2.3,\
1865100.9,1.4,100.0,2.4";
1866
1867        let temp_file = std::env::temp_dir().join("test_interleaved_snapshot5.csv");
1868        std::fs::write(&temp_file, csv_data).unwrap();
1869
1870        let depths = load_depth_from_snapshot5(&temp_file, None, None, None, Some(1)).unwrap();
1871        assert_eq!(depths.len(), 1);
1872
1873        let depth = &depths[0];
1874
1875        // Verify bids are correctly parsed (should be decreasing)
1876        assert_eq!(depth.bids[0].price, Price::from("100.4"));
1877        assert_eq!(depth.bids[1].price, Price::from("100.3"));
1878        assert_eq!(depth.bids[2].price, Price::from("100.2"));
1879        assert_eq!(depth.bids[3].price, Price::from("100.1"));
1880        assert_eq!(depth.bids[4].price, Price::from("100.0"));
1881
1882        // Verify asks are correctly parsed (should be increasing)
1883        assert_eq!(depth.asks[0].price, Price::from("100.5"));
1884        assert_eq!(depth.asks[1].price, Price::from("100.6"));
1885        assert_eq!(depth.asks[2].price, Price::from("100.7"));
1886        assert_eq!(depth.asks[3].price, Price::from("100.8"));
1887        assert_eq!(depth.asks[4].price, Price::from("100.9"));
1888
1889        // Verify sizes
1890        assert_eq!(depth.bids[0].size, Quantity::from("2.0"));
1891        assert_eq!(depth.asks[0].size, Quantity::from("1.0"));
1892
1893        std::fs::remove_file(temp_file).unwrap();
1894    }
1895
1896    #[rstest]
1897    fn test_load_deltas_limit_includes_clear_deltas() {
1898        // Test that limit counts total emitted deltas (including CLEARs)
1899        // When limit=5, we should get exactly 5 deltas: 1 CLEAR + 4 data deltas
1900        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1901binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1902binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
1903binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
1904binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
1905binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49998.0,0.5
1906binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50003.0,2.0
1907binance-futures,BTCUSDT,1640995205000000,1640995205100000,false,bid,49997.0,0.5";
1908
1909        let temp_file = std::env::temp_dir().join("test_load_deltas_limit.csv");
1910        std::fs::write(&temp_file, csv_data).unwrap();
1911
1912        // Load with limit=5 (should emit exactly 5 deltas including CLEAR)
1913        let deltas = load_deltas(&temp_file, Some(1), Some(1), None, Some(5)).unwrap();
1914
1915        // Should have exactly 5 deltas: 1 CLEAR + 4 data deltas
1916        assert_eq!(deltas.len(), 5);
1917        assert_eq!(deltas[0].action, BookAction::Clear);
1918        assert_eq!(deltas[1].action, BookAction::Add);
1919        assert_eq!(deltas[2].action, BookAction::Add);
1920        assert_eq!(deltas[3].action, BookAction::Update);
1921        assert_eq!(deltas[4].action, BookAction::Update);
1922
1923        // Verify the last delta is from the 4th CSV record (49999.0 bid)
1924        assert_eq!(deltas[3].order.price, parse_price(49999.0, 1));
1925
1926        std::fs::remove_file(&temp_file).ok();
1927    }
1928
1929    #[rstest]
1930    fn test_load_deltas_limit_stops_at_clear() {
1931        // Test that limit=1 with snapshot data returns only the CLEAR delta
1932        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1933binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1934binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0";
1935
1936        let temp_file = std::env::temp_dir().join("test_load_deltas_limit_stops_at_clear.csv");
1937        std::fs::write(&temp_file, csv_data).unwrap();
1938
1939        // Load with limit=1 should only get the CLEAR delta
1940        let deltas = load_deltas(&temp_file, Some(1), Some(1), None, Some(1)).unwrap();
1941
1942        assert_eq!(deltas.len(), 1);
1943        assert_eq!(deltas[0].action, BookAction::Clear);
1944
1945        std::fs::remove_file(&temp_file).ok();
1946    }
1947
1948    #[rstest]
1949    fn test_load_deltas_with_consecutive_snapshots_inserts_clear() {
1950        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1951hyperliquid,BTC,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1952hyperliquid,BTC,1640995200000001,1640995200100000,true,ask,50001.0,2.0
1953hyperliquid,BTC,1640995201000000,1640995201100000,true,bid,49990.0,3.0
1954hyperliquid,BTC,1640995201000001,1640995201100000,true,ask,49991.0,4.0";
1955
1956        let temp_file = std::env::temp_dir().join("test_load_deltas_consecutive_snapshots.csv");
1957        std::fs::write(&temp_file, csv_data).unwrap();
1958
1959        let deltas = load_deltas(&temp_file, Some(1), Some(1), None, None).unwrap();
1960        let clear_count = deltas
1961            .iter()
1962            .filter(|d| d.action == BookAction::Clear)
1963            .count();
1964
1965        assert_eq!(clear_count, 2);
1966        assert_eq!(deltas[0].action, BookAction::Clear);
1967        assert_eq!(deltas[3].action, BookAction::Clear);
1968        assert_eq!(
1969            deltas[2].flags & RecordFlag::F_LAST as u8,
1970            RecordFlag::F_LAST as u8
1971        );
1972        assert_eq!(deltas[3].flags & RecordFlag::F_LAST as u8, 0);
1973        assert_eq!(
1974            deltas
1975                .iter()
1976                .map(|delta| (delta.action, delta.flags, delta.ts_event, delta.ts_init))
1977                .collect::<Vec<_>>(),
1978            vec![
1979                (
1980                    BookAction::Clear,
1981                    RecordFlag::F_SNAPSHOT as u8,
1982                    UnixNanos::from(1_640_995_200_000_000_000),
1983                    UnixNanos::from(1_640_995_200_100_000_000),
1984                ),
1985                (
1986                    BookAction::Add,
1987                    0,
1988                    UnixNanos::from(1_640_995_200_000_000_000),
1989                    UnixNanos::from(1_640_995_200_100_000_000),
1990                ),
1991                (
1992                    BookAction::Add,
1993                    RecordFlag::F_LAST as u8,
1994                    UnixNanos::from(1_640_995_200_000_001_000),
1995                    UnixNanos::from(1_640_995_200_100_000_000),
1996                ),
1997                (
1998                    BookAction::Clear,
1999                    RecordFlag::F_SNAPSHOT as u8,
2000                    UnixNanos::from(1_640_995_201_000_000_000),
2001                    UnixNanos::from(1_640_995_201_100_000_000),
2002                ),
2003                (
2004                    BookAction::Add,
2005                    0,
2006                    UnixNanos::from(1_640_995_201_000_000_000),
2007                    UnixNanos::from(1_640_995_201_100_000_000),
2008                ),
2009                (
2010                    BookAction::Add,
2011                    RecordFlag::F_LAST as u8,
2012                    UnixNanos::from(1_640_995_201_000_001_000),
2013                    UnixNanos::from(1_640_995_201_100_000_000),
2014                ),
2015            ]
2016        );
2017
2018        std::fs::remove_file(&temp_file).ok();
2019    }
2020
2021    #[rstest]
2022    fn test_load_deltas_limit_with_mid_day_snapshot() {
2023        // Test limit behavior when there's a mid-day snapshot
2024        // The limit counts total emitted deltas including CLEARs
2025        let filepath = get_test_data_path("csv/deltas_with_snapshot.csv");
2026        let deltas = load_deltas(filepath, Some(1), Some(1), None, Some(5)).unwrap();
2027
2028        // With limit=5, we get exactly 5 deltas
2029        // First snapshot inserts CLEAR, then we get 4 more data deltas
2030        assert_eq!(deltas.len(), 5);
2031        assert_eq!(deltas[0].action, BookAction::Clear);
2032    }
2033
2034    // Curates the large Tardis Deribit CSV.gz into NautilusTrader Parquet format.
2035    // Run manually: `cargo test -p nautilus-tardis --features arrow test_curate_deribit_deltas -- --ignored --nocapture`
2036    #[cfg(feature = "arrow")]
2037    #[rstest]
2038    #[ignore = "one-time dataset curation, not for routine CI"]
2039    fn test_curate_deribit_deltas() {
2040        let csv_path = get_test_data_root()
2041            .join("large")
2042            .join("tardis_deribit_incremental_book_L2_2020-04-01_BTC-PERPETUAL.csv.gz");
2043
2044        let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
2045        let parquet_path = "/tmp/tardis_BTC-PERPETUAL.DERIBIT_2020-04-01_deltas.parquet";
2046
2047        println!("Loading deltas from {}", csv_path.display());
2048        let deltas = load_deltas(&csv_path, None, None, Some(instrument_id), None).unwrap();
2049        let count = deltas.len();
2050        println!("Loaded {count} deltas");
2051
2052        let sample = deltas
2053            .iter()
2054            .find(|d| d.order.price.precision > 0)
2055            .expect("Should have at least one non-CLEAR delta");
2056        let price_precision = sample.order.price.precision;
2057        let size_precision = sample.order.size.precision;
2058        println!("Precision: price={price_precision}, size={size_precision}");
2059
2060        // Write in chunks to avoid stack overflow on large batches
2061        let metadata =
2062            OrderBookDelta::get_metadata(&instrument_id, price_precision, size_precision);
2063        let schema = OrderBookDelta::get_schema(Some(metadata.clone()));
2064
2065        println!("Writing Parquet to {parquet_path}");
2066        let file = File::create(parquet_path).unwrap();
2067        let zstd_level = parquet::basic::ZstdLevel::try_new(3).unwrap();
2068        let props = WriterProperties::builder()
2069            .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
2070            .set_max_row_group_row_count(Some(1_000_000))
2071            .build();
2072        let mut writer = ArrowWriter::try_new(file, Arc::new(schema), Some(props)).unwrap();
2073
2074        let chunk_size = 1_000_000;
2075        for (i, chunk) in deltas.chunks(chunk_size).enumerate() {
2076            println!("  Encoding chunk {} ({} records)...", i + 1, chunk.len());
2077            let batch = OrderBookDelta::encode_batch(&metadata, chunk).unwrap();
2078            writer.write(&batch).unwrap();
2079        }
2080        writer.close().unwrap();
2081
2082        let file_size = fs::metadata(parquet_path).unwrap().len();
2083        println!("\n=== CURATION COMPLETE ===");
2084        println!("Records: {count}");
2085        println!("Price precision: {price_precision}");
2086        println!("Size precision: {size_precision}");
2087        println!(
2088            "File size: {} bytes ({:.1} MB)",
2089            file_size,
2090            file_size as f64 / 1_048_576.0
2091        );
2092        println!("Output: {parquet_path}");
2093        println!("\nNext steps:");
2094        println!("  sha256sum {parquet_path}");
2095    }
2096}