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