Skip to main content

nautilus_tardis/csv/
stream.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::{io::Read, path::Path};
17
18use ahash::AHashMap;
19use csv::{Reader, StringRecord};
20use nautilus_core::{UnixNanos, correctness::check_in_range_inclusive_usize};
21#[cfg(feature = "python")]
22use nautilus_model::{data::OrderBookDeltas, python::data::data_to_pyobject};
23use nautilus_model::{
24    data::{DEPTH10_LEN, Data, NULL_ORDER, OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick},
25    enums::{OrderSide, RecordFlag},
26    identifiers::InstrumentId,
27    types::Quantity,
28};
29#[cfg(feature = "python")]
30use pyo3::{Py, PyAny, PyResult, Python};
31
32use crate::{
33    common::parse::{parse_instrument_id, parse_timestamp},
34    csv::{
35        create_book_order, create_csv_reader, infer_precision,
36        load::OptionsChainPrecision,
37        matches_underlying_filter, normalize_underlying_filters, parse_delta_record,
38        parse_derivative_ticker_record, parse_options_chain_record,
39        parse_options_chain_record_as_quote, parse_quote_record, parse_trade_record,
40        record::{
41            TardisBookUpdateRecord, TardisOptionsChainRecord, TardisOrderBookSnapshot5Record,
42            TardisOrderBookSnapshot25Record, TardisQuoteRecord, TardisTradeRecord,
43        },
44    },
45};
46
47const MAX_STREAM_CHUNK_SIZE: usize = 1_000_000;
48
49fn validate_stream_chunk_size(chunk_size: usize) -> anyhow::Result<()> {
50    check_in_range_inclusive_usize(chunk_size, 1, MAX_STREAM_CHUNK_SIZE, stringify!(chunk_size))?;
51    Ok(())
52}
53
54fn options_chain_buffer_capacity(chunk_size: usize) -> anyhow::Result<usize> {
55    chunk_size
56        .checked_mul(2)
57        .ok_or_else(|| anyhow::anyhow!("options chain buffer capacity overflow"))
58}
59
60////////////////////////////////////////////////////////////////////////////////
61// OrderBookDelta Streaming
62////////////////////////////////////////////////////////////////////////////////
63
64/// Streaming iterator over CSV records that yields chunks of parsed data.
65struct DeltaStreamIterator {
66    reader: Reader<Box<dyn std::io::Read>>,
67    record: StringRecord,
68    buffer: Vec<OrderBookDelta>,
69    chunk_size: usize,
70    instrument_id: Option<InstrumentId>,
71    price_precision: u8,
72    size_precision: u8,
73    last_ts_init: Option<UnixNanos>,
74    last_is_snapshot: bool,
75    seen_first_snapshot: bool,
76    skipped_before_snapshot: usize,
77    limit: Option<usize>,
78    deltas_emitted: usize,
79
80    pending: Option<anyhow::Result<TardisBookUpdateRecord>>,
81}
82
83impl DeltaStreamIterator {
84    /// Creates a new [`DeltaStreamIterator`].
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the file cannot be opened or read.
89    fn new<P: AsRef<Path>>(
90        filepath: P,
91        chunk_size: usize,
92        price_precision: Option<u8>,
93        size_precision: Option<u8>,
94        instrument_id: Option<InstrumentId>,
95        limit: Option<usize>,
96    ) -> anyhow::Result<Self> {
97        let (final_price_precision, final_size_precision) =
98            if let (Some(price_prec), Some(size_prec)) = (price_precision, size_precision) {
99                // Both precisions provided, use them directly
100                (price_prec, size_prec)
101            } else {
102                // One or both precisions missing, detect only the missing ones
103                let mut reader = create_csv_reader(&filepath)?;
104                let mut record = StringRecord::new();
105                let (detected_price, detected_size) =
106                    Self::detect_precision_from_sample(&mut reader, &mut record, 10_000);
107                (
108                    price_precision.unwrap_or(detected_price),
109                    size_precision.unwrap_or(detected_size),
110                )
111            };
112
113        let reader = create_csv_reader(filepath)?;
114
115        Ok(Self {
116            reader,
117            record: StringRecord::new(),
118            buffer: Vec::with_capacity(chunk_size),
119            chunk_size,
120            instrument_id,
121            price_precision: final_price_precision,
122            size_precision: final_size_precision,
123            last_ts_init: None,
124            last_is_snapshot: false,
125            seen_first_snapshot: false,
126            skipped_before_snapshot: 0,
127            limit,
128            deltas_emitted: 0,
129            pending: None,
130        })
131    }
132
133    fn detect_precision_from_sample(
134        reader: &mut Reader<Box<dyn std::io::Read>>,
135        record: &mut StringRecord,
136        sample_size: usize,
137    ) -> (u8, u8) {
138        let mut max_price_precision = 0u8;
139        let mut max_size_precision = 0u8;
140        let mut records_scanned = 0;
141
142        while records_scanned < sample_size {
143            match reader.read_record(record) {
144                Ok(true) => {
145                    if let Ok(data) = record.deserialize::<TardisBookUpdateRecord>(None) {
146                        max_price_precision = max_price_precision.max(infer_precision(data.price));
147                        max_size_precision = max_size_precision.max(infer_precision(data.amount));
148                        records_scanned += 1;
149                    }
150                }
151                Ok(false) => break,             // End of file
152                Err(_) => records_scanned += 1, // Skip malformed records
153            }
154        }
155
156        (max_price_precision, max_size_precision)
157    }
158}
159
160impl Iterator for DeltaStreamIterator {
161    type Item = anyhow::Result<Vec<OrderBookDelta>>;
162
163    fn next(&mut self) -> Option<Self::Item> {
164        if let Some(limit) = self.limit
165            && self.deltas_emitted >= limit
166        {
167            return None;
168        }
169
170        self.buffer.clear();
171
172        loop {
173            if self.buffer.len() >= self.chunk_size {
174                break;
175            }
176
177            if let Some(limit) = self.limit
178                && self.deltas_emitted >= limit
179            {
180                break;
181            }
182
183            let data = match self.pending.take() {
184                Some(Ok(data)) => data,
185                Some(Err(e)) => return Some(Err(e)),
186                None => match self.read_record() {
187                    Ok(Some(data)) => data,
188                    Ok(None) => {
189                        if !self.seen_first_snapshot && self.skipped_before_snapshot > 0 {
190                            log::warn!(
191                                "No snapshot row found in Tardis CSV: all {} row(s) were \
192                                 pre-snapshot buffered records and have been skipped, zero deltas \
193                                 will be produced (see https://docs.tardis.dev/faq/order-books)",
194                                self.skipped_before_snapshot,
195                            );
196                            // Reset so a repeated call after exhaustion (out-of-spec but
197                            // harmless) does not log the same summary again.
198                            self.skipped_before_snapshot = 0;
199                        }
200
201                        if self.buffer.is_empty() {
202                            return None;
203                        }
204
205                        if let Some(last_delta) = self.buffer.last_mut() {
206                            last_delta.flags = RecordFlag::F_LAST as u8;
207                        }
208                        return Some(Ok(self.buffer.clone()));
209                    }
210                    Err(e) => return Some(Err(e)),
211                },
212            };
213
214            // Rows before the first snapshot are pre-snapshot orphans and must be skipped, see
215            // https://docs.tardis.dev/faq/order-books
216            if !self.seen_first_snapshot {
217                if !data.is_snapshot {
218                    self.skipped_before_snapshot += 1;
219                    continue;
220                }
221
222                if self.skipped_before_snapshot > 0 {
223                    log::warn!(
224                        "Skipped {} pre-snapshot buffered delta record(s) for {}/{} (received \
225                         before the first snapshot row, see \
226                         https://docs.tardis.dev/faq/order-books)",
227                        self.skipped_before_snapshot,
228                        data.exchange,
229                        data.symbol,
230                    );
231                }
232                self.seen_first_snapshot = true;
233            }
234
235            let ts_event = parse_timestamp(data.timestamp);
236            let ts_init = parse_timestamp(data.local_timestamp);
237
238            // Insert CLEAR on snapshot boundary to reset order book state.
239            // Some venues emit every book event as a full snapshot, so a new
240            // snapshot message must also reset the previous snapshot state.
241            let starts_new_snapshot =
242                data.is_snapshot && (!self.last_is_snapshot || self.last_ts_init != Some(ts_init));
243
244            if starts_new_snapshot {
245                let clear_instrument_id = self
246                    .instrument_id
247                    .unwrap_or_else(|| parse_instrument_id(&data.exchange, data.symbol));
248
249                if self.last_ts_init != Some(ts_init)
250                    && let Some(last_delta) = self.buffer.last_mut()
251                {
252                    last_delta.flags = RecordFlag::F_LAST as u8;
253                }
254                self.last_ts_init = Some(ts_init);
255
256                let clear_delta = OrderBookDelta::clear(clear_instrument_id, 0, ts_event, ts_init);
257                self.buffer.push(clear_delta);
258                self.deltas_emitted += 1;
259
260                // Defer real delta to next chunk if constraints reached
261                if self.buffer.len() >= self.chunk_size
262                    || self.limit.is_some_and(|l| self.deltas_emitted >= l)
263                {
264                    self.last_is_snapshot = data.is_snapshot;
265                    self.pending = Some(Ok(data));
266                    break;
267                }
268            }
269            self.last_is_snapshot = data.is_snapshot;
270
271            let delta = match parse_delta_record(
272                &data,
273                self.price_precision,
274                self.size_precision,
275                self.instrument_id,
276            ) {
277                Ok(d) => d,
278                Err(e) => {
279                    log::warn!("Skipping invalid delta record: {e}");
280                    continue;
281                }
282            };
283
284            if self.last_ts_init != Some(delta.ts_init)
285                && let Some(last_delta) = self.buffer.last_mut()
286            {
287                last_delta.flags = RecordFlag::F_LAST as u8;
288            }
289
290            self.last_ts_init = Some(delta.ts_init);
291
292            self.buffer.push(delta);
293            self.deltas_emitted += 1;
294
295            if self.buffer.len() >= self.chunk_size
296                && !self.limit.is_some_and(|l| self.deltas_emitted >= l)
297            {
298                match self.read_record() {
299                    Ok(Some(data)) => {
300                        let next_ts_init = parse_timestamp(data.local_timestamp);
301                        if self.last_ts_init != Some(next_ts_init)
302                            && let Some(last_delta) = self.buffer.last_mut()
303                        {
304                            last_delta.flags = RecordFlag::F_LAST as u8;
305                        }
306                        self.pending = Some(Ok(data));
307                    }
308                    Ok(None) => {
309                        if let Some(last_delta) = self.buffer.last_mut() {
310                            last_delta.flags = RecordFlag::F_LAST as u8;
311                        }
312                    }
313                    Err(e) => self.pending = Some(Err(e)),
314                }
315                break;
316            }
317        }
318
319        if self.buffer.is_empty() {
320            None
321        } else {
322            // Only set F_LAST when limit reached (stream ending), not on chunk
323            // boundary where more deltas from the same message may follow
324            if let Some(limit) = self.limit
325                && self.deltas_emitted >= limit
326                && let Some(last_delta) = self.buffer.last_mut()
327            {
328                last_delta.flags = RecordFlag::F_LAST as u8;
329            }
330            Some(Ok(self.buffer.clone()))
331        }
332    }
333}
334
335impl DeltaStreamIterator {
336    fn read_record(&mut self) -> anyhow::Result<Option<TardisBookUpdateRecord>> {
337        if !self
338            .reader
339            .read_record(&mut self.record)
340            .map_err(|e| anyhow::anyhow!("Failed to read record: {e}"))?
341        {
342            return Ok(None);
343        }
344
345        self.record
346            .deserialize::<TardisBookUpdateRecord>(None)
347            .map(Some)
348            .map_err(|e| anyhow::anyhow!("Failed to deserialize record: {e}"))
349    }
350}
351
352/// Streams [`OrderBookDelta`]s from a Tardis format CSV at the given `filepath`,
353/// yielding chunks of the specified size.
354///
355/// # Precision Inference Warning
356///
357/// When using streaming with precision inference (not providing explicit precisions),
358/// the inferred precision may differ from bulk loading the entire file. This is because
359/// precision inference works within chunk boundaries, and different chunks may contain
360/// values with different precision requirements. For deterministic precision behavior,
361/// provide explicit `price_precision` and `size_precision` parameters.
362///
363/// # Errors
364///
365/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
366/// read, or parsed as CSV.
367pub fn stream_deltas<P: AsRef<Path>>(
368    filepath: P,
369    chunk_size: usize,
370    price_precision: Option<u8>,
371    size_precision: Option<u8>,
372    instrument_id: Option<InstrumentId>,
373    limit: Option<usize>,
374) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<OrderBookDelta>>>> {
375    validate_stream_chunk_size(chunk_size)?;
376    DeltaStreamIterator::new(
377        filepath,
378        chunk_size,
379        price_precision,
380        size_precision,
381        instrument_id,
382        limit,
383    )
384}
385
386#[cfg(feature = "python")]
387/// Streaming iterator over CSV records that yields chunks of parsed data.
388struct BatchedDeltasStreamIterator {
389    reader: Reader<Box<dyn std::io::Read>>,
390    record: StringRecord,
391    current_batch: Vec<OrderBookDelta>,
392    pending_batches: Vec<Vec<OrderBookDelta>>,
393    chunk_size: usize,
394    instrument_id: InstrumentId,
395    price_precision: u8,
396    size_precision: u8,
397    last_ts_init: Option<UnixNanos>,
398    last_is_snapshot: bool,
399    seen_first_snapshot: bool,
400    skipped_before_snapshot: usize,
401    limit: Option<usize>,
402    deltas_emitted: usize,
403}
404
405#[cfg(feature = "python")]
406impl BatchedDeltasStreamIterator {
407    /// Creates a new [`DeltaStreamIterator`].
408    ///
409    /// # Errors
410    ///
411    /// Returns an error if the file cannot be opened or read.
412    fn new<P: AsRef<Path>>(
413        filepath: P,
414        chunk_size: usize,
415        price_precision: Option<u8>,
416        size_precision: Option<u8>,
417        instrument_id: Option<InstrumentId>,
418        limit: Option<usize>,
419    ) -> anyhow::Result<Self> {
420        let mut reader = create_csv_reader(&filepath)?;
421        let mut record = StringRecord::new();
422
423        let first_record = if reader.read_record(&mut record)? {
424            record.deserialize::<TardisBookUpdateRecord>(None)?
425        } else {
426            anyhow::bail!("CSV file is empty");
427        };
428
429        let final_instrument_id = instrument_id
430            .unwrap_or_else(|| parse_instrument_id(&first_record.exchange, first_record.symbol));
431
432        let (final_price_precision, final_size_precision) =
433            if let (Some(price_prec), Some(size_prec)) = (price_precision, size_precision) {
434                // Both precisions provided, use them directly
435                (price_prec, size_prec)
436            } else {
437                // One or both precisions missing, detect from sample including first record
438                let (detected_price, detected_size) =
439                    Self::detect_precision_from_sample(&mut reader, &mut record, 10_000);
440                (
441                    price_precision.unwrap_or(detected_price),
442                    size_precision.unwrap_or(detected_size),
443                )
444            };
445
446        let reader = create_csv_reader(filepath)?;
447
448        Ok(Self {
449            reader,
450            record: StringRecord::new(),
451            current_batch: Vec::new(),
452            pending_batches: Vec::with_capacity(chunk_size),
453            chunk_size,
454            instrument_id: final_instrument_id,
455            price_precision: final_price_precision,
456            size_precision: final_size_precision,
457            last_ts_init: None,
458            last_is_snapshot: false,
459            seen_first_snapshot: false,
460            skipped_before_snapshot: 0,
461            limit,
462            deltas_emitted: 0,
463        })
464    }
465
466    fn detect_precision_from_sample(
467        reader: &mut Reader<Box<dyn std::io::Read>>,
468        record: &mut StringRecord,
469        sample_size: usize,
470    ) -> (u8, u8) {
471        let mut max_price_precision = 0u8;
472        let mut max_size_precision = 0u8;
473        let mut records_scanned = 0;
474
475        while records_scanned < sample_size {
476            match reader.read_record(record) {
477                Ok(true) => {
478                    if let Ok(data) = record.deserialize::<TardisBookUpdateRecord>(None) {
479                        max_price_precision = max_price_precision.max(infer_precision(data.price));
480                        max_size_precision = max_size_precision.max(infer_precision(data.amount));
481                        records_scanned += 1;
482                    }
483                }
484                Ok(false) => break,             // End of file
485                Err(_) => records_scanned += 1, // Skip malformed records
486            }
487        }
488
489        (max_price_precision, max_size_precision)
490    }
491
492    fn fill_pending_batches(&mut self) -> Option<anyhow::Result<()>> {
493        self.pending_batches.clear();
494        let mut batches_created = 0;
495
496        while batches_created < self.chunk_size {
497            if let Some(limit) = self.limit
498                && self.deltas_emitted >= limit
499            {
500                break;
501            }
502
503            match self.reader.read_record(&mut self.record) {
504                Ok(true) => {
505                    let data = match self.record.deserialize::<TardisBookUpdateRecord>(None) {
506                        Ok(data) => data,
507                        Err(e) => {
508                            return Some(Err(anyhow::anyhow!("Failed to deserialize record: {e}")));
509                        }
510                    };
511
512                    // Rows before the first snapshot are pre-snapshot orphans and must be
513                    // skipped, see https://docs.tardis.dev/faq/order-books
514                    if !self.seen_first_snapshot {
515                        if !data.is_snapshot {
516                            self.skipped_before_snapshot += 1;
517                            continue;
518                        }
519
520                        if self.skipped_before_snapshot > 0 {
521                            log::warn!(
522                                "Skipped {} pre-snapshot buffered delta record(s) for {}/{} \
523                                 (received before the first snapshot row, see \
524                                 https://docs.tardis.dev/faq/order-books)",
525                                self.skipped_before_snapshot,
526                                data.exchange,
527                                data.symbol,
528                            );
529                        }
530                        self.seen_first_snapshot = true;
531                    }
532
533                    let ts_event = parse_timestamp(data.timestamp);
534                    let ts_init = parse_timestamp(data.local_timestamp);
535
536                    // Parse before any state changes so invalid records
537                    // don't corrupt batch boundaries or snapshot tracking
538                    let delta = match parse_delta_record(
539                        &data,
540                        self.price_precision,
541                        self.size_precision,
542                        Some(self.instrument_id),
543                    ) {
544                        Ok(d) => d,
545                        Err(e) => {
546                            log::warn!("Skipping invalid delta record: {e}");
547                            continue;
548                        }
549                    };
550
551                    let starts_new_message = self.last_ts_init != Some(ts_init);
552
553                    if starts_new_message && !self.current_batch.is_empty() {
554                        // Set F_LAST on the last delta of the completed batch
555                        if let Some(last_delta) = self.current_batch.last_mut() {
556                            last_delta.flags = RecordFlag::F_LAST as u8;
557                        }
558                        self.pending_batches
559                            .push(std::mem::take(&mut self.current_batch));
560                        batches_created += 1;
561                    }
562
563                    // Insert CLEAR on snapshot boundary to reset order book state.
564                    // Some venues emit every book event as a full snapshot, so a new
565                    // snapshot message must also reset the previous snapshot state.
566                    if data.is_snapshot && (!self.last_is_snapshot || starts_new_message) {
567                        let clear_delta =
568                            OrderBookDelta::clear(self.instrument_id, 0, ts_event, ts_init);
569                        self.current_batch.push(clear_delta);
570                        self.deltas_emitted += 1;
571
572                        if let Some(limit) = self.limit
573                            && self.deltas_emitted >= limit
574                        {
575                            self.last_is_snapshot = data.is_snapshot;
576                            break;
577                        }
578                    }
579                    self.last_ts_init = Some(ts_init);
580                    self.last_is_snapshot = data.is_snapshot;
581
582                    self.current_batch.push(delta);
583                    self.deltas_emitted += 1;
584
585                    if let Some(limit) = self.limit
586                        && self.deltas_emitted >= limit
587                    {
588                        break;
589                    }
590                }
591                Ok(false) => {
592                    // End of file
593                    if !self.seen_first_snapshot && self.skipped_before_snapshot > 0 {
594                        log::warn!(
595                            "No snapshot row found in Tardis CSV: all {} row(s) were \
596                             pre-snapshot buffered records and have been skipped, zero deltas \
597                             will be produced (see https://docs.tardis.dev/faq/order-books)",
598                            self.skipped_before_snapshot,
599                        );
600                        // Reset so a repeated call after exhaustion (out-of-spec but harmless)
601                        // does not log the same summary again.
602                        self.skipped_before_snapshot = 0;
603                    }
604                    break;
605                }
606                Err(e) => return Some(Err(anyhow::anyhow!("Failed to read record: {e}"))),
607            }
608        }
609
610        if !self.current_batch.is_empty() && batches_created < self.chunk_size {
611            // Ensure the last delta of the last batch has F_LAST set
612            if let Some(last_delta) = self.current_batch.last_mut() {
613                last_delta.flags = RecordFlag::F_LAST as u8;
614            }
615            self.pending_batches
616                .push(std::mem::take(&mut self.current_batch));
617        }
618
619        if self.pending_batches.is_empty() {
620            None
621        } else {
622            Some(Ok(()))
623        }
624    }
625}
626
627#[cfg(feature = "python")]
628impl Iterator for BatchedDeltasStreamIterator {
629    type Item = anyhow::Result<Vec<Py<PyAny>>>;
630
631    fn next(&mut self) -> Option<Self::Item> {
632        if let Some(limit) = self.limit
633            && self.deltas_emitted >= limit
634        {
635            return None;
636        }
637
638        if let Some(Err(e)) = self.fill_pending_batches() {
639            return Some(Err(e));
640        }
641
642        if self.pending_batches.is_empty() {
643            None
644        } else {
645            let batches = std::mem::take(&mut self.pending_batches);
646            let result = Python::attach(|py| {
647                batches
648                    .into_iter()
649                    .map(|batch| {
650                        let deltas = OrderBookDeltas::new(self.instrument_id, batch);
651                        let deltas = Box::new(deltas);
652                        data_to_pyobject(py, Data::BookDeltas(deltas))
653                    })
654                    .collect::<PyResult<Vec<_>>>()
655            })
656            .map_err(|e| anyhow::anyhow!("Failed to convert batched deltas to Python: {e}"));
657            Some(result)
658        }
659    }
660}
661
662#[cfg(feature = "python")]
663/// Streams batches of `OrderBookDeltas` Python objects from a Tardis format CSV at the given
664/// `filepath`, yielding chunks of the specified size.
665///
666/// # Errors
667///
668/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
669/// read, or parsed as CSV.
670pub fn stream_batched_deltas<P: AsRef<Path>>(
671    filepath: P,
672    chunk_size: usize,
673    price_precision: Option<u8>,
674    size_precision: Option<u8>,
675    instrument_id: Option<InstrumentId>,
676    limit: Option<usize>,
677) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<Py<PyAny>>>>> {
678    validate_stream_chunk_size(chunk_size)?;
679    BatchedDeltasStreamIterator::new(
680        filepath,
681        chunk_size,
682        price_precision,
683        size_precision,
684        instrument_id,
685        limit,
686    )
687}
688
689////////////////////////////////////////////////////////////////////////////////
690// Quote Streaming
691////////////////////////////////////////////////////////////////////////////////
692
693/// An iterator for streaming [`QuoteTick`]s from a Tardis CSV file in chunks.
694struct QuoteStreamIterator {
695    reader: Reader<Box<dyn Read>>,
696    record: StringRecord,
697    buffer: Vec<QuoteTick>,
698    chunk_size: usize,
699    instrument_id: Option<InstrumentId>,
700    price_precision: u8,
701    size_precision: u8,
702    limit: Option<usize>,
703    records_processed: usize,
704}
705
706impl QuoteStreamIterator {
707    /// Creates a new [`QuoteStreamIterator`].
708    ///
709    /// # Errors
710    ///
711    /// Returns an error if the file cannot be opened or read.
712    pub(crate) fn new<P: AsRef<Path>>(
713        filepath: P,
714        chunk_size: usize,
715        price_precision: Option<u8>,
716        size_precision: Option<u8>,
717        instrument_id: Option<InstrumentId>,
718        limit: Option<usize>,
719    ) -> anyhow::Result<Self> {
720        let (final_price_precision, final_size_precision) =
721            if let (Some(price_prec), Some(size_prec)) = (price_precision, size_precision) {
722                // Both precisions provided, use them directly
723                (price_prec, size_prec)
724            } else {
725                // One or both precisions missing, detect only the missing ones
726                let mut reader = create_csv_reader(&filepath)?;
727                let mut record = StringRecord::new();
728                let (detected_price, detected_size) =
729                    Self::detect_precision_from_sample(&mut reader, &mut record, 10_000);
730                (
731                    price_precision.unwrap_or(detected_price),
732                    size_precision.unwrap_or(detected_size),
733                )
734            };
735
736        let reader = create_csv_reader(filepath)?;
737
738        Ok(Self {
739            reader,
740            record: StringRecord::new(),
741            buffer: Vec::with_capacity(chunk_size),
742            chunk_size,
743            instrument_id,
744            price_precision: final_price_precision,
745            size_precision: final_size_precision,
746            limit,
747            records_processed: 0,
748        })
749    }
750
751    fn detect_precision_from_sample(
752        reader: &mut Reader<Box<dyn std::io::Read>>,
753        record: &mut StringRecord,
754        sample_size: usize,
755    ) -> (u8, u8) {
756        let mut max_price_precision = 2u8;
757        let mut max_size_precision = 0u8;
758        let mut records_scanned = 0;
759
760        while records_scanned < sample_size {
761            match reader.read_record(record) {
762                Ok(true) => {
763                    if let Ok(data) = record.deserialize::<TardisQuoteRecord>(None) {
764                        if let Some(bid_price_val) = data.bid_price {
765                            max_price_precision =
766                                max_price_precision.max(infer_precision(bid_price_val));
767                        }
768
769                        if let Some(ask_price_val) = data.ask_price {
770                            max_price_precision =
771                                max_price_precision.max(infer_precision(ask_price_val));
772                        }
773
774                        if let Some(bid_amount_val) = data.bid_amount {
775                            max_size_precision =
776                                max_size_precision.max(infer_precision(bid_amount_val));
777                        }
778
779                        if let Some(ask_amount_val) = data.ask_amount {
780                            max_size_precision =
781                                max_size_precision.max(infer_precision(ask_amount_val));
782                        }
783                        records_scanned += 1;
784                    }
785                }
786                Ok(false) => break,             // End of file
787                Err(_) => records_scanned += 1, // Skip malformed records
788            }
789        }
790
791        (max_price_precision, max_size_precision)
792    }
793}
794
795impl Iterator for QuoteStreamIterator {
796    type Item = anyhow::Result<Vec<QuoteTick>>;
797
798    fn next(&mut self) -> Option<Self::Item> {
799        if let Some(limit) = self.limit
800            && self.records_processed >= limit
801        {
802            return None;
803        }
804
805        self.buffer.clear();
806        let mut records_read = 0;
807
808        while records_read < self.chunk_size {
809            match self.reader.read_record(&mut self.record) {
810                Ok(true) => match self.record.deserialize::<TardisQuoteRecord>(None) {
811                    Ok(data) => {
812                        let quote = parse_quote_record(
813                            &data,
814                            self.price_precision,
815                            self.size_precision,
816                            self.instrument_id,
817                        );
818
819                        self.buffer.push(quote);
820                        records_read += 1;
821                        self.records_processed += 1;
822
823                        if let Some(limit) = self.limit
824                            && self.records_processed >= limit
825                        {
826                            break;
827                        }
828                    }
829                    Err(e) => {
830                        return Some(Err(anyhow::anyhow!("Failed to deserialize record: {e}")));
831                    }
832                },
833                Ok(false) => {
834                    if self.buffer.is_empty() {
835                        return None;
836                    }
837                    return Some(Ok(self.buffer.clone()));
838                }
839                Err(e) => return Some(Err(anyhow::anyhow!("Failed to read record: {e}"))),
840            }
841        }
842
843        if self.buffer.is_empty() {
844            None
845        } else {
846            Some(Ok(self.buffer.clone()))
847        }
848    }
849}
850
851/// Streams [`QuoteTick`]s from a Tardis format CSV at the given `filepath`,
852/// yielding chunks of the specified size.
853///
854/// # Precision Inference Warning
855///
856/// When using streaming with precision inference (not providing explicit precisions),
857/// the inferred precision may differ from bulk loading the entire file. This is because
858/// precision inference works within chunk boundaries, and different chunks may contain
859/// values with different precision requirements. For deterministic precision behavior,
860/// provide explicit `price_precision` and `size_precision` parameters.
861///
862/// # Errors
863///
864/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
865/// read, or parsed as CSV.
866pub fn stream_quotes<P: AsRef<Path>>(
867    filepath: P,
868    chunk_size: usize,
869    price_precision: Option<u8>,
870    size_precision: Option<u8>,
871    instrument_id: Option<InstrumentId>,
872    limit: Option<usize>,
873) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<QuoteTick>>>> {
874    validate_stream_chunk_size(chunk_size)?;
875    QuoteStreamIterator::new(
876        filepath,
877        chunk_size,
878        price_precision,
879        size_precision,
880        instrument_id,
881        limit,
882    )
883}
884
885struct OptionsChainStreamIterator {
886    reader: Reader<Box<dyn Read>>,
887    record: StringRecord,
888    buffer: Vec<Data>,
889    chunk_size: usize,
890    underlyings: Option<Vec<String>>,
891    price_precision: Option<u8>,
892    size_precision: Option<u8>,
893    precision_by_instrument: AHashMap<InstrumentId, OptionsChainPrecision>,
894    limit: Option<usize>,
895    records_processed: usize,
896}
897
898impl OptionsChainStreamIterator {
899    pub(crate) fn new<P: AsRef<Path>>(
900        filepath: P,
901        chunk_size: usize,
902        underlyings: Option<Vec<String>>,
903        price_precision: Option<u8>,
904        size_precision: Option<u8>,
905        limit: Option<usize>,
906    ) -> anyhow::Result<Self> {
907        let buffer_capacity = options_chain_buffer_capacity(chunk_size)?;
908        let underlyings = normalize_underlying_filters(underlyings);
909        let mut precision_by_instrument = AHashMap::new();
910
911        if price_precision.is_none() || size_precision.is_none() {
912            let mut reader = create_csv_reader(&filepath)?;
913            let mut record = StringRecord::new();
914            Self::detect_precision_from_sample(
915                &mut reader,
916                &mut record,
917                underlyings.as_deref(),
918                price_precision,
919                size_precision,
920                &mut precision_by_instrument,
921                10_000,
922            );
923        }
924
925        let reader = create_csv_reader(filepath)?;
926
927        Ok(Self {
928            reader,
929            record: StringRecord::new(),
930            buffer: Vec::with_capacity(buffer_capacity),
931            chunk_size,
932            underlyings,
933            price_precision,
934            size_precision,
935            precision_by_instrument,
936            limit,
937            records_processed: 0,
938        })
939    }
940
941    fn detect_precision_from_sample(
942        reader: &mut Reader<Box<dyn Read>>,
943        record: &mut StringRecord,
944        underlyings: Option<&[String]>,
945        price_precision: Option<u8>,
946        size_precision: Option<u8>,
947        precision_by_instrument: &mut AHashMap<InstrumentId, OptionsChainPrecision>,
948        sample_size: usize,
949    ) {
950        let mut records_scanned = 0;
951
952        while records_scanned < sample_size {
953            match reader.read_record(record) {
954                Ok(true) => {
955                    if let Some(underlyings) = underlyings {
956                        let Some(symbol) = record.get(1) else {
957                            records_scanned += 1;
958                            continue;
959                        };
960                        let symbol = symbol.to_uppercase();
961                        if !matches_underlying_filter(&symbol, Some(underlyings)) {
962                            records_scanned += 1;
963                            continue;
964                        }
965                    }
966
967                    if let Ok(data) = record.deserialize::<TardisOptionsChainRecord>(None) {
968                        let instrument_id = parse_instrument_id(&data.exchange, data.symbol);
969                        precision_by_instrument
970                            .entry(instrument_id)
971                            .or_insert_with(|| {
972                                OptionsChainPrecision::new(price_precision, size_precision)
973                            })
974                            .update(&data, price_precision, size_precision);
975                    }
976                    records_scanned += 1;
977                }
978                Ok(false) => break,
979                Err(_) => records_scanned += 1,
980            }
981        }
982    }
983}
984
985impl Iterator for OptionsChainStreamIterator {
986    type Item = anyhow::Result<Vec<Data>>;
987
988    fn next(&mut self) -> Option<Self::Item> {
989        if let Some(limit) = self.limit
990            && self.records_processed >= limit
991        {
992            return None;
993        }
994
995        self.buffer.clear();
996        let mut records_read = 0;
997
998        while records_read < self.chunk_size {
999            match self.reader.read_record(&mut self.record) {
1000                Ok(true) => {
1001                    if let Some(underlyings) = self.underlyings.as_deref() {
1002                        let Some(symbol) = self.record.get(1) else {
1003                            continue;
1004                        };
1005                        let symbol = symbol.to_uppercase();
1006                        if !matches_underlying_filter(&symbol, Some(underlyings)) {
1007                            continue;
1008                        }
1009                    }
1010
1011                    let data = match self.record.deserialize::<TardisOptionsChainRecord>(None) {
1012                        Ok(data) => data,
1013                        Err(e) => {
1014                            return Some(Err(anyhow::anyhow!("Failed to deserialize record: {e}")));
1015                        }
1016                    };
1017                    let instrument_id = parse_instrument_id(&data.exchange, data.symbol);
1018                    let precision = self
1019                        .precision_by_instrument
1020                        .entry(instrument_id)
1021                        .or_insert_with(|| {
1022                            OptionsChainPrecision::new(self.price_precision, self.size_precision)
1023                        });
1024                    precision.update(&data, self.price_precision, self.size_precision);
1025
1026                    match parse_options_chain_record_as_quote(
1027                        &data,
1028                        precision.price,
1029                        precision.size,
1030                        instrument_id,
1031                    ) {
1032                        Ok(Some(quote)) => self.buffer.push(Data::Quote(quote)),
1033                        Ok(None) => {}
1034                        Err(e) => return Some(Err(e)),
1035                    }
1036                    self.buffer
1037                        .push(Data::OptionGreeks(parse_options_chain_record(
1038                            &data,
1039                            instrument_id,
1040                        )));
1041
1042                    records_read += 1;
1043                    self.records_processed += 1;
1044
1045                    if let Some(limit) = self.limit
1046                        && self.records_processed >= limit
1047                    {
1048                        break;
1049                    }
1050                }
1051                Ok(false) => {
1052                    if self.buffer.is_empty() {
1053                        return None;
1054                    }
1055                    return Some(Ok(self.buffer.clone()));
1056                }
1057                Err(e) => return Some(Err(anyhow::anyhow!("Failed to read record: {e}"))),
1058            }
1059        }
1060
1061        if self.buffer.is_empty() {
1062            None
1063        } else {
1064            Some(Ok(self.buffer.clone()))
1065        }
1066    }
1067}
1068
1069/// Streams Tardis `options_chain` CSV rows as quote and option greeks data.
1070///
1071/// # Precision Inference Warning
1072///
1073/// When using streaming with precision inference, later rows can raise the inferred precision for
1074/// their instrument after earlier chunks have already been emitted. Provide explicit precision
1075/// parameters for deterministic precision behavior.
1076///
1077/// # Errors
1078///
1079/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
1080/// read, or parsed as CSV.
1081pub fn stream_options_chain<P: AsRef<Path>>(
1082    filepath: P,
1083    chunk_size: usize,
1084    underlyings: Option<Vec<String>>,
1085    price_precision: Option<u8>,
1086    size_precision: Option<u8>,
1087    limit: Option<usize>,
1088) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<Data>>>> {
1089    validate_stream_chunk_size(chunk_size)?;
1090    OptionsChainStreamIterator::new(
1091        filepath,
1092        chunk_size,
1093        underlyings,
1094        price_precision,
1095        size_precision,
1096        limit,
1097    )
1098}
1099
1100////////////////////////////////////////////////////////////////////////////////
1101// Trade Streaming
1102////////////////////////////////////////////////////////////////////////////////
1103
1104/// An iterator for streaming [`TradeTick`]s from a Tardis CSV file in chunks.
1105struct TradeStreamIterator {
1106    reader: Reader<Box<dyn Read>>,
1107    record: StringRecord,
1108    buffer: Vec<TradeTick>,
1109    chunk_size: usize,
1110    instrument_id: Option<InstrumentId>,
1111    price_precision: u8,
1112    size_precision: u8,
1113    limit: Option<usize>,
1114    records_processed: usize,
1115}
1116
1117impl TradeStreamIterator {
1118    /// Creates a new [`TradeStreamIterator`].
1119    ///
1120    /// # Errors
1121    ///
1122    /// Returns an error if the file cannot be opened or read.
1123    pub(crate) fn new<P: AsRef<Path>>(
1124        filepath: P,
1125        chunk_size: usize,
1126        price_precision: Option<u8>,
1127        size_precision: Option<u8>,
1128        instrument_id: Option<InstrumentId>,
1129        limit: Option<usize>,
1130    ) -> anyhow::Result<Self> {
1131        let (final_price_precision, final_size_precision) =
1132            if let (Some(price_prec), Some(size_prec)) = (price_precision, size_precision) {
1133                // Both precisions provided, use them directly
1134                (price_prec, size_prec)
1135            } else {
1136                // One or both precisions missing, detect only the missing ones
1137                let mut reader = create_csv_reader(&filepath)?;
1138                let mut record = StringRecord::new();
1139                let (detected_price, detected_size) =
1140                    Self::detect_precision_from_sample(&mut reader, &mut record, 10_000);
1141                (
1142                    price_precision.unwrap_or(detected_price),
1143                    size_precision.unwrap_or(detected_size),
1144                )
1145            };
1146
1147        let reader = create_csv_reader(filepath)?;
1148
1149        Ok(Self {
1150            reader,
1151            record: StringRecord::new(),
1152            buffer: Vec::with_capacity(chunk_size),
1153            chunk_size,
1154            instrument_id,
1155            price_precision: final_price_precision,
1156            size_precision: final_size_precision,
1157            limit,
1158            records_processed: 0,
1159        })
1160    }
1161
1162    fn detect_precision_from_sample(
1163        reader: &mut Reader<Box<dyn std::io::Read>>,
1164        record: &mut StringRecord,
1165        sample_size: usize,
1166    ) -> (u8, u8) {
1167        let mut max_price_precision = 2u8;
1168        let mut max_size_precision = 0u8;
1169        let mut records_scanned = 0;
1170
1171        while records_scanned < sample_size {
1172            match reader.read_record(record) {
1173                Ok(true) => {
1174                    if let Ok(data) = record.deserialize::<TardisTradeRecord>(None) {
1175                        max_price_precision = max_price_precision.max(infer_precision(data.price));
1176                        max_size_precision = max_size_precision.max(infer_precision(data.amount));
1177                        records_scanned += 1;
1178                    }
1179                }
1180                Ok(false) => break,             // End of file
1181                Err(_) => records_scanned += 1, // Skip malformed records
1182            }
1183        }
1184
1185        (max_price_precision, max_size_precision)
1186    }
1187}
1188
1189impl Iterator for TradeStreamIterator {
1190    type Item = anyhow::Result<Vec<TradeTick>>;
1191
1192    fn next(&mut self) -> Option<Self::Item> {
1193        if let Some(limit) = self.limit
1194            && self.records_processed >= limit
1195        {
1196            return None;
1197        }
1198
1199        self.buffer.clear();
1200        let mut records_read = 0;
1201
1202        while records_read < self.chunk_size {
1203            match self.reader.read_record(&mut self.record) {
1204                Ok(true) => match self.record.deserialize::<TardisTradeRecord>(None) {
1205                    Ok(data) => {
1206                        let size = Quantity::new(data.amount, self.size_precision);
1207
1208                        if size.is_positive() {
1209                            let trade = parse_trade_record(
1210                                &data,
1211                                size,
1212                                self.price_precision,
1213                                self.instrument_id,
1214                            );
1215
1216                            self.buffer.push(trade);
1217                            records_read += 1;
1218                            self.records_processed += 1;
1219
1220                            if let Some(limit) = self.limit
1221                                && self.records_processed >= limit
1222                            {
1223                                break;
1224                            }
1225                        } else {
1226                            log::warn!("Skipping zero-sized trade: {data:?}");
1227                        }
1228                    }
1229                    Err(e) => {
1230                        return Some(Err(anyhow::anyhow!("Failed to deserialize record: {e}")));
1231                    }
1232                },
1233                Ok(false) => {
1234                    if self.buffer.is_empty() {
1235                        return None;
1236                    }
1237                    return Some(Ok(self.buffer.clone()));
1238                }
1239                Err(e) => return Some(Err(anyhow::anyhow!("Failed to read record: {e}"))),
1240            }
1241        }
1242
1243        if self.buffer.is_empty() {
1244            None
1245        } else {
1246            Some(Ok(self.buffer.clone()))
1247        }
1248    }
1249}
1250
1251/// Streams [`TradeTick`]s from a Tardis format CSV at the given `filepath`,
1252/// yielding chunks of the specified size.
1253///
1254/// # Precision Inference Warning
1255///
1256/// When using streaming with precision inference (not providing explicit precisions),
1257/// the inferred precision may differ from bulk loading the entire file. This is because
1258/// precision inference works within chunk boundaries, and different chunks may contain
1259/// values with different precision requirements. For deterministic precision behavior,
1260/// provide explicit `price_precision` and `size_precision` parameters.
1261///
1262/// # Errors
1263///
1264/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
1265/// read, or parsed as CSV.
1266pub fn stream_trades<P: AsRef<Path>>(
1267    filepath: P,
1268    chunk_size: usize,
1269    price_precision: Option<u8>,
1270    size_precision: Option<u8>,
1271    instrument_id: Option<InstrumentId>,
1272    limit: Option<usize>,
1273) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<TradeTick>>>> {
1274    validate_stream_chunk_size(chunk_size)?;
1275    TradeStreamIterator::new(
1276        filepath,
1277        chunk_size,
1278        price_precision,
1279        size_precision,
1280        instrument_id,
1281        limit,
1282    )
1283}
1284
1285////////////////////////////////////////////////////////////////////////////////
1286// Depth Streaming
1287////////////////////////////////////////////////////////////////////////////////
1288
1289/// An iterator for streaming [`OrderBookDepth`]s from a Tardis CSV file in chunks.
1290struct DepthStreamIterator {
1291    reader: Reader<Box<dyn Read>>,
1292    record: StringRecord,
1293    buffer: Vec<OrderBookDepth>,
1294    chunk_size: usize,
1295    levels: u8,
1296    instrument_id: Option<InstrumentId>,
1297    price_precision: u8,
1298    size_precision: u8,
1299    limit: Option<usize>,
1300    records_processed: usize,
1301}
1302
1303impl DepthStreamIterator {
1304    /// Creates a new [`DepthStreamIterator`].
1305    ///
1306    /// # Errors
1307    ///
1308    /// Returns an error if the file cannot be opened or read, or if `levels` is not 5 or 25.
1309    pub(crate) fn new<P: AsRef<Path>>(
1310        filepath: P,
1311        chunk_size: usize,
1312        levels: u8,
1313        price_precision: Option<u8>,
1314        size_precision: Option<u8>,
1315        instrument_id: Option<InstrumentId>,
1316        limit: Option<usize>,
1317    ) -> anyhow::Result<Self> {
1318        anyhow::ensure!(
1319            levels == 5 || levels == 25,
1320            "Invalid levels: {levels}. Must be 5 or 25."
1321        );
1322
1323        let (final_price_precision, final_size_precision) =
1324            if let (Some(price_prec), Some(size_prec)) = (price_precision, size_precision) {
1325                // Both precisions provided, use them directly
1326                (price_prec, size_prec)
1327            } else {
1328                // One or both precisions missing, detect only the missing ones
1329                let mut reader = create_csv_reader(&filepath)?;
1330                let mut record = StringRecord::new();
1331                let (detected_price, detected_size) =
1332                    Self::detect_precision_from_sample(&mut reader, &mut record, 10_000);
1333                (
1334                    price_precision.unwrap_or(detected_price),
1335                    size_precision.unwrap_or(detected_size),
1336                )
1337            };
1338
1339        let reader = create_csv_reader(filepath)?;
1340
1341        Ok(Self {
1342            reader,
1343            record: StringRecord::new(),
1344            buffer: Vec::with_capacity(chunk_size),
1345            chunk_size,
1346            levels,
1347            instrument_id,
1348            price_precision: final_price_precision,
1349            size_precision: final_size_precision,
1350            limit,
1351            records_processed: 0,
1352        })
1353    }
1354
1355    fn process_snapshot5(&self, data: &TardisOrderBookSnapshot5Record) -> OrderBookDepth {
1356        let instrument_id = self
1357            .instrument_id
1358            .unwrap_or_else(|| parse_instrument_id(&data.exchange, data.symbol));
1359
1360        let mut bids = [NULL_ORDER; DEPTH10_LEN];
1361        let mut asks = [NULL_ORDER; DEPTH10_LEN];
1362        let mut bid_counts = [0_u32; DEPTH10_LEN];
1363        let mut ask_counts = [0_u32; DEPTH10_LEN];
1364
1365        // Process first 5 levels from snapshot5 data
1366        for i in 0..5 {
1367            let (bid_price, bid_amount) = match i {
1368                0 => (data.bids_0_price, data.bids_0_amount),
1369                1 => (data.bids_1_price, data.bids_1_amount),
1370                2 => (data.bids_2_price, data.bids_2_amount),
1371                3 => (data.bids_3_price, data.bids_3_amount),
1372                4 => (data.bids_4_price, data.bids_4_amount),
1373                _ => unreachable!(),
1374            };
1375
1376            let (ask_price, ask_amount) = match i {
1377                0 => (data.asks_0_price, data.asks_0_amount),
1378                1 => (data.asks_1_price, data.asks_1_amount),
1379                2 => (data.asks_2_price, data.asks_2_amount),
1380                3 => (data.asks_3_price, data.asks_3_amount),
1381                4 => (data.asks_4_price, data.asks_4_amount),
1382                _ => unreachable!(),
1383            };
1384
1385            let (bid_order, bid_count) = create_book_order(
1386                OrderSide::Buy,
1387                bid_price,
1388                bid_amount,
1389                self.price_precision,
1390                self.size_precision,
1391            );
1392            bids[i] = bid_order;
1393            bid_counts[i] = bid_count;
1394
1395            let (ask_order, ask_count) = create_book_order(
1396                OrderSide::Sell,
1397                ask_price,
1398                ask_amount,
1399                self.price_precision,
1400                self.size_precision,
1401            );
1402            asks[i] = ask_order;
1403            ask_counts[i] = ask_count;
1404        }
1405
1406        let flags = RecordFlag::F_SNAPSHOT as u8;
1407        let sequence = 0;
1408        let ts_event = parse_timestamp(data.timestamp);
1409        let ts_init = parse_timestamp(data.local_timestamp);
1410
1411        OrderBookDepth::new(
1412            instrument_id,
1413            bids,
1414            asks,
1415            bid_counts,
1416            ask_counts,
1417            flags,
1418            sequence,
1419            ts_event,
1420            ts_init,
1421        )
1422    }
1423
1424    fn process_snapshot25(&self, data: &TardisOrderBookSnapshot25Record) -> OrderBookDepth {
1425        let instrument_id = self
1426            .instrument_id
1427            .unwrap_or_else(|| parse_instrument_id(&data.exchange, data.symbol));
1428
1429        let mut bids = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
1430        let mut asks = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
1431        let mut bid_counts = [0_u32; TardisOrderBookSnapshot25Record::LEVELS];
1432        let mut ask_counts = [0_u32; TardisOrderBookSnapshot25Record::LEVELS];
1433
1434        // Process all 25 levels from snapshot25 data
1435        for i in 0..TardisOrderBookSnapshot25Record::LEVELS {
1436            let (bid_price, bid_amount) = data.bid_level(i);
1437            let (ask_price, ask_amount) = data.ask_level(i);
1438
1439            let (bid_order, bid_count) = create_book_order(
1440                OrderSide::Buy,
1441                bid_price,
1442                bid_amount,
1443                self.price_precision,
1444                self.size_precision,
1445            );
1446            bids[i] = bid_order;
1447            bid_counts[i] = bid_count;
1448
1449            let (ask_order, ask_count) = create_book_order(
1450                OrderSide::Sell,
1451                ask_price,
1452                ask_amount,
1453                self.price_precision,
1454                self.size_precision,
1455            );
1456            asks[i] = ask_order;
1457            ask_counts[i] = ask_count;
1458        }
1459
1460        let flags = RecordFlag::F_SNAPSHOT as u8;
1461        let sequence = 0;
1462        let ts_event = parse_timestamp(data.timestamp);
1463        let ts_init = parse_timestamp(data.local_timestamp);
1464
1465        OrderBookDepth::new(
1466            instrument_id,
1467            bids,
1468            asks,
1469            bid_counts,
1470            ask_counts,
1471            flags,
1472            sequence,
1473            ts_event,
1474            ts_init,
1475        )
1476    }
1477
1478    fn detect_precision_from_sample(
1479        reader: &mut Reader<Box<dyn std::io::Read>>,
1480        record: &mut StringRecord,
1481        sample_size: usize,
1482    ) -> (u8, u8) {
1483        let mut max_price_precision = 2u8;
1484        let mut max_size_precision = 0u8;
1485        let mut records_scanned = 0;
1486
1487        while records_scanned < sample_size {
1488            match reader.read_record(record) {
1489                Ok(true) => {
1490                    // Try to deserialize as snapshot5 record first
1491                    if let Ok(data) = record.deserialize::<TardisOrderBookSnapshot5Record>(None) {
1492                        if let Some(bid_price) = data.bids_0_price {
1493                            max_price_precision =
1494                                max_price_precision.max(infer_precision(bid_price));
1495                        }
1496
1497                        if let Some(ask_price) = data.asks_0_price {
1498                            max_price_precision =
1499                                max_price_precision.max(infer_precision(ask_price));
1500                        }
1501
1502                        if let Some(bid_amount) = data.bids_0_amount {
1503                            max_size_precision =
1504                                max_size_precision.max(infer_precision(bid_amount));
1505                        }
1506
1507                        if let Some(ask_amount) = data.asks_0_amount {
1508                            max_size_precision =
1509                                max_size_precision.max(infer_precision(ask_amount));
1510                        }
1511                        records_scanned += 1;
1512                    } else if let Ok(data) =
1513                        record.deserialize::<TardisOrderBookSnapshot25Record>(None)
1514                    {
1515                        if let Some(bid_price) = data.bids_0_price {
1516                            max_price_precision =
1517                                max_price_precision.max(infer_precision(bid_price));
1518                        }
1519
1520                        if let Some(ask_price) = data.asks_0_price {
1521                            max_price_precision =
1522                                max_price_precision.max(infer_precision(ask_price));
1523                        }
1524
1525                        if let Some(bid_amount) = data.bids_0_amount {
1526                            max_size_precision =
1527                                max_size_precision.max(infer_precision(bid_amount));
1528                        }
1529
1530                        if let Some(ask_amount) = data.asks_0_amount {
1531                            max_size_precision =
1532                                max_size_precision.max(infer_precision(ask_amount));
1533                        }
1534                        records_scanned += 1;
1535                    }
1536                }
1537                Ok(false) => break,             // End of file
1538                Err(_) => records_scanned += 1, // Skip malformed records
1539            }
1540        }
1541
1542        (max_price_precision, max_size_precision)
1543    }
1544}
1545
1546impl Iterator for DepthStreamIterator {
1547    type Item = anyhow::Result<Vec<OrderBookDepth>>;
1548
1549    fn next(&mut self) -> Option<Self::Item> {
1550        if let Some(limit) = self.limit
1551            && self.records_processed >= limit
1552        {
1553            return None;
1554        }
1555
1556        if !self.buffer.is_empty() {
1557            let chunk = self.buffer.split_off(0);
1558            return Some(Ok(chunk));
1559        }
1560
1561        self.buffer.clear();
1562        let mut records_read = 0;
1563
1564        while records_read < self.chunk_size {
1565            match self.reader.read_record(&mut self.record) {
1566                Ok(true) => {
1567                    let result = match self.levels {
1568                        5 => self
1569                            .record
1570                            .deserialize::<TardisOrderBookSnapshot5Record>(None)
1571                            .map(|data| self.process_snapshot5(&data)),
1572                        25 => self
1573                            .record
1574                            .deserialize::<TardisOrderBookSnapshot25Record>(None)
1575                            .map(|data| self.process_snapshot25(&data)),
1576                        _ => return Some(Err(anyhow::anyhow!("Invalid levels: {}", self.levels))),
1577                    };
1578
1579                    match result {
1580                        Ok(depth) => {
1581                            self.buffer.push(depth);
1582                            records_read += 1;
1583                            self.records_processed += 1;
1584
1585                            if let Some(limit) = self.limit
1586                                && self.records_processed >= limit
1587                            {
1588                                break;
1589                            }
1590                        }
1591                        Err(e) => {
1592                            return Some(Err(anyhow::anyhow!("Failed to deserialize record: {e}")));
1593                        }
1594                    }
1595                }
1596                Ok(false) => {
1597                    if self.buffer.is_empty() {
1598                        return None;
1599                    }
1600                    let chunk = self.buffer.split_off(0);
1601                    return Some(Ok(chunk));
1602                }
1603                Err(e) => return Some(Err(anyhow::anyhow!("Failed to read record: {e}"))),
1604            }
1605        }
1606
1607        if self.buffer.is_empty() {
1608            None
1609        } else {
1610            let chunk = self.buffer.split_off(0);
1611            Some(Ok(chunk))
1612        }
1613    }
1614}
1615
1616/// Streams [`OrderBookDepth`]s from a Tardis format CSV at the given `filepath`,
1617/// yielding chunks of the specified size.
1618///
1619/// # Precision Inference Warning
1620///
1621/// When using streaming with precision inference (not providing explicit precisions),
1622/// the inferred precision may differ from bulk loading the entire file. This is because
1623/// precision inference works within chunk boundaries, and different chunks may contain
1624/// values with different precision requirements. For deterministic precision behavior,
1625/// provide explicit `price_precision` and `size_precision` parameters.
1626///
1627/// # Errors
1628///
1629/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
1630/// read, or parsed as CSV.
1631pub fn stream_depth_from_snapshot5<P: AsRef<Path>>(
1632    filepath: P,
1633    chunk_size: usize,
1634    price_precision: Option<u8>,
1635    size_precision: Option<u8>,
1636    instrument_id: Option<InstrumentId>,
1637    limit: Option<usize>,
1638) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<OrderBookDepth>>>> {
1639    validate_stream_chunk_size(chunk_size)?;
1640    DepthStreamIterator::new(
1641        filepath,
1642        chunk_size,
1643        5,
1644        price_precision,
1645        size_precision,
1646        instrument_id,
1647        limit,
1648    )
1649}
1650
1651/// Streams [`OrderBookDepth`]s from a Tardis format CSV at the given `filepath`,
1652/// yielding chunks of the specified size.
1653///
1654/// # Precision Inference Warning
1655///
1656/// When using streaming with precision inference (not providing explicit precisions),
1657/// the inferred precision may differ from bulk loading the entire file. This is because
1658/// precision inference works within chunk boundaries, and different chunks may contain
1659/// values with different precision requirements. For deterministic precision behavior,
1660/// provide explicit `price_precision` and `size_precision` parameters.
1661///
1662/// # Errors
1663///
1664/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
1665/// read, or parsed as CSV.
1666pub fn stream_depth_from_snapshot25<P: AsRef<Path>>(
1667    filepath: P,
1668    chunk_size: usize,
1669    price_precision: Option<u8>,
1670    size_precision: Option<u8>,
1671    instrument_id: Option<InstrumentId>,
1672    limit: Option<usize>,
1673) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<OrderBookDepth>>>> {
1674    validate_stream_chunk_size(chunk_size)?;
1675    DepthStreamIterator::new(
1676        filepath,
1677        chunk_size,
1678        25,
1679        price_precision,
1680        size_precision,
1681        instrument_id,
1682        limit,
1683    )
1684}
1685
1686////////////////////////////////////////////////////////////////////////////////
1687// FundingRateUpdate Streaming
1688////////////////////////////////////////////////////////////////////////////////
1689
1690use nautilus_model::data::FundingRateUpdate;
1691
1692use crate::csv::record::TardisDerivativeTickerRecord;
1693
1694/// An iterator for streaming [`FundingRateUpdate`]s from a Tardis CSV file in chunks.
1695struct FundingRateStreamIterator {
1696    reader: Reader<Box<dyn Read>>,
1697    record: StringRecord,
1698    buffer: Vec<FundingRateUpdate>,
1699    chunk_size: usize,
1700    instrument_id: Option<InstrumentId>,
1701    limit: Option<usize>,
1702    records_processed: usize,
1703}
1704
1705impl FundingRateStreamIterator {
1706    /// Creates a new [`FundingRateStreamIterator`].
1707    ///
1708    /// # Errors
1709    ///
1710    /// Returns an error if the file cannot be opened or read.
1711    fn new<P: AsRef<Path>>(
1712        filepath: P,
1713        chunk_size: usize,
1714        instrument_id: Option<InstrumentId>,
1715        limit: Option<usize>,
1716    ) -> anyhow::Result<Self> {
1717        let reader = create_csv_reader(filepath)?;
1718
1719        Ok(Self {
1720            reader,
1721            record: StringRecord::new(),
1722            buffer: Vec::with_capacity(chunk_size),
1723            chunk_size,
1724            instrument_id,
1725            limit,
1726            records_processed: 0,
1727        })
1728    }
1729}
1730
1731impl Iterator for FundingRateStreamIterator {
1732    type Item = anyhow::Result<Vec<FundingRateUpdate>>;
1733
1734    fn next(&mut self) -> Option<Self::Item> {
1735        if let Some(limit) = self.limit
1736            && self.records_processed >= limit
1737        {
1738            return None;
1739        }
1740
1741        if !self.buffer.is_empty() {
1742            let chunk = self.buffer.split_off(0);
1743            return Some(Ok(chunk));
1744        }
1745
1746        self.buffer.clear();
1747        let mut records_read = 0;
1748
1749        while records_read < self.chunk_size {
1750            match self.reader.read_record(&mut self.record) {
1751                Ok(true) => {
1752                    let result = self
1753                        .record
1754                        .deserialize::<TardisDerivativeTickerRecord>(None)
1755                        .map_err(anyhow::Error::from)
1756                        .map(|data| parse_derivative_ticker_record(&data, self.instrument_id));
1757
1758                    match result {
1759                        Ok(Some(funding_rate)) => {
1760                            self.buffer.push(funding_rate);
1761                            records_read += 1;
1762                            self.records_processed += 1;
1763
1764                            if let Some(limit) = self.limit
1765                                && self.records_processed >= limit
1766                            {
1767                                break;
1768                            }
1769                        }
1770                        Ok(None) => {
1771                            // Skip this record as it has no funding data
1772                            self.records_processed += 1;
1773                        }
1774                        Err(e) => {
1775                            return Some(Err(anyhow::anyhow!(
1776                                "Failed to parse funding rate record: {e}"
1777                            )));
1778                        }
1779                    }
1780                }
1781                Ok(false) => {
1782                    if self.buffer.is_empty() {
1783                        return None;
1784                    }
1785                    let chunk = self.buffer.split_off(0);
1786                    return Some(Ok(chunk));
1787                }
1788                Err(e) => return Some(Err(anyhow::anyhow!("Failed to read record: {e}"))),
1789            }
1790        }
1791
1792        if self.buffer.is_empty() {
1793            None
1794        } else {
1795            let chunk = self.buffer.split_off(0);
1796            Some(Ok(chunk))
1797        }
1798    }
1799}
1800
1801/// Streams [`FundingRateUpdate`]s from a Tardis derivative ticker CSV file,
1802/// yielding chunks of the specified size.
1803///
1804/// This function parses the `funding_rate` and `funding_timestamp` fields from derivative ticker
1805/// data to create funding rate updates.
1806///
1807/// # Errors
1808///
1809/// Returns an error if `chunk_size` is outside `[1, 1_000_000]`, or if the file cannot be opened,
1810/// read, or parsed as CSV.
1811pub fn stream_funding_rates<P: AsRef<Path>>(
1812    filepath: P,
1813    chunk_size: usize,
1814    instrument_id: Option<InstrumentId>,
1815    limit: Option<usize>,
1816) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<FundingRateUpdate>>>> {
1817    validate_stream_chunk_size(chunk_size)?;
1818    FundingRateStreamIterator::new(filepath, chunk_size, instrument_id, limit)
1819}
1820
1821#[cfg(test)]
1822mod tests {
1823    use nautilus_model::{
1824        enums::{AggressorSide, BookAction},
1825        identifiers::{InstrumentId, TradeId},
1826        types::Price,
1827    };
1828    use rstest::*;
1829
1830    use super::*;
1831    use crate::{common::testing::get_test_data_path, csv::load::load_deltas};
1832
1833    #[rstest]
1834    #[case(1)]
1835    #[case(100_000)]
1836    #[case(MAX_STREAM_CHUNK_SIZE)]
1837    fn test_validate_stream_chunk_size_accepts_supported_values(#[case] chunk_size: usize) {
1838        assert!(validate_stream_chunk_size(chunk_size).is_ok());
1839    }
1840
1841    #[rstest]
1842    #[case(0)]
1843    #[case(MAX_STREAM_CHUNK_SIZE + 1)]
1844    #[case(usize::MAX)]
1845    fn test_validate_stream_chunk_size_rejects_invalid_values(#[case] chunk_size: usize) {
1846        assert!(validate_stream_chunk_size(chunk_size).is_err());
1847    }
1848
1849    #[rstest]
1850    fn test_options_chain_buffer_capacity_is_checked() {
1851        assert_eq!(
1852            options_chain_buffer_capacity(MAX_STREAM_CHUNK_SIZE).unwrap(),
1853            MAX_STREAM_CHUNK_SIZE * 2,
1854        );
1855        assert!(options_chain_buffer_capacity(usize::MAX).is_err());
1856    }
1857
1858    #[rstest]
1859    fn test_quote_stream_allocates_maximum_supported_chunk() {
1860        let csv_data = "exchange,symbol,timestamp,local_timestamp,ask_amount,ask_price,bid_price,bid_amount\n\
1861             binance,BTCUSDT,1640995200000000,1640995200100000,1.0,50000.0,49999.0,1.5";
1862        let temp_file = tempfile::NamedTempFile::new().unwrap();
1863        std::fs::write(temp_file.path(), csv_data).unwrap();
1864
1865        let stream = QuoteStreamIterator::new(
1866            temp_file.path(),
1867            MAX_STREAM_CHUNK_SIZE,
1868            Some(1),
1869            Some(1),
1870            None,
1871            None,
1872        )
1873        .unwrap();
1874
1875        assert!(stream.buffer.capacity() >= MAX_STREAM_CHUNK_SIZE);
1876    }
1877
1878    #[rstest]
1879    #[case(0.0, 0)]
1880    #[case(42.0, 0)]
1881    #[case(0.1, 1)]
1882    #[case(0.25, 2)]
1883    #[case(123.0001, 4)]
1884    #[case(-42.987654321,       9)]
1885    #[case(1.234_567_890_123, 12)]
1886    fn test_infer_precision(#[case] input: f64, #[case] expected: u8) {
1887        assert_eq!(infer_precision(input), expected);
1888    }
1889
1890    #[rstest]
1891    pub fn test_stream_deltas_chunked() {
1892        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1893binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
1894binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0
1895binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50000.12,1.5
1896binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49999.123,3.0
1897binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50000.1234,0.5";
1898
1899        let temp_file = std::env::temp_dir().join("test_stream_deltas.csv");
1900        std::fs::write(&temp_file, csv_data).unwrap();
1901
1902        let stream = stream_deltas(&temp_file, 2, Some(4), Some(1), None, None).unwrap();
1903        let chunks: Vec<_> = stream.collect();
1904
1905        // 5 data rows + 1 CLEAR = 6 deltas, in chunks of 2
1906        assert_eq!(chunks.len(), 3);
1907
1908        let chunk1 = chunks[0].as_ref().unwrap();
1909        assert_eq!(chunk1.len(), 2);
1910        assert_eq!(chunk1[0].action, BookAction::Clear); // CLEAR first
1911        assert_eq!(chunk1[1].order.price.precision, 4); // First data delta
1912
1913        let chunk2 = chunks[1].as_ref().unwrap();
1914        assert_eq!(chunk2.len(), 2);
1915        assert_eq!(chunk2[0].order.price.precision, 4);
1916        assert_eq!(chunk2[1].order.price.precision, 4);
1917
1918        let chunk3 = chunks[2].as_ref().unwrap();
1919        assert_eq!(chunk3.len(), 2);
1920        assert_eq!(chunk3[0].order.price.precision, 4);
1921        assert_eq!(chunk3[1].order.price.precision, 4);
1922
1923        let total_deltas: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
1924        assert_eq!(total_deltas, 6);
1925
1926        std::fs::remove_file(&temp_file).ok();
1927    }
1928
1929    #[rstest]
1930    #[case(2, vec![2, 2, 2])]
1931    #[case(3, vec![3, 3])]
1932    fn test_stream_deltas_groups_messages_by_local_timestamp(
1933        #[case] chunk_size: usize,
1934        #[case] expected_chunk_lengths: Vec<usize>,
1935    ) {
1936        let filepath = get_test_data_path("csv/deltas_message_boundaries.csv");
1937        let expected = load_deltas(&filepath, Some(1), Some(1), None, None).unwrap();
1938        let chunks = stream_deltas(&filepath, chunk_size, Some(1), Some(1), None, None)
1939            .unwrap()
1940            .map(Result::unwrap)
1941            .collect::<Vec<_>>();
1942
1943        assert_eq!(
1944            chunks.iter().map(Vec::len).collect::<Vec<_>>(),
1945            expected_chunk_lengths
1946        );
1947        assert_eq!(chunks.into_iter().flatten().collect::<Vec<_>>(), expected);
1948    }
1949
1950    #[rstest]
1951    fn test_stream_deltas_defers_lookahead_error() {
1952        // Leading snapshot row (distinct local_timestamp) establishes book state so the two
1953        // valid delta rows that follow are not treated as pre-snapshot orphans.
1954        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1955deribit,BTC-PERPETUAL,900,1900,true,bid,98.0,9.0
1956deribit,BTC-PERPETUAL,1000,2000,false,bid,100.0,1.0
1957deribit,BTC-PERPETUAL,1000,2000,false,ask,101.0,2.0
1958deribit,BTC-PERPETUAL,invalid,2010,false,bid,99.0,3.0";
1959        let temp_file = tempfile::NamedTempFile::new().unwrap();
1960        std::fs::write(temp_file.path(), csv_data).unwrap();
1961        let mut stream = stream_deltas(temp_file.path(), 2, Some(1), Some(1), None, None).unwrap();
1962
1963        // First chunk: the leading snapshot row alone (CLEAR + Add).
1964        let snapshot_chunk = stream.next().unwrap().unwrap();
1965        assert_eq!(snapshot_chunk.len(), 2);
1966        assert_eq!(snapshot_chunk[0].action, BookAction::Clear);
1967
1968        let chunk = stream.next().unwrap().unwrap();
1969        let error = stream.next().unwrap().unwrap_err();
1970
1971        assert_eq!(chunk.len(), 2);
1972        assert_eq!(chunk[0].order.price, Price::from("100.0"));
1973        assert_eq!(chunk[1].order.price, Price::from("101.0"));
1974        assert_eq!(chunk[1].flags, 0);
1975        assert!(
1976            error
1977                .to_string()
1978                .starts_with("Failed to deserialize record: CSV deserialize error:")
1979        );
1980        assert!(stream.next().is_none());
1981    }
1982
1983    #[cfg(feature = "python")]
1984    #[rstest]
1985    pub fn test_stream_batched_deltas_clear_and_limit() {
1986        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1987binance,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
1988binance,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0
1989binance,BTCUSDT,1640995202000000,1640995202100000,false,ask,50000.12,1.5
1990binance,BTCUSDT,1640995203000000,1640995203100000,false,bid,49999.123,3.0
1991binance,BTCUSDT,1640995204000000,1640995204100000,false,ask,50000.1234,0.5";
1992
1993        let temp_file = std::env::temp_dir().join("test_stream_batched_deltas.csv");
1994        std::fs::write(&temp_file, csv_data).unwrap();
1995
1996        // limit=1 should return only the synthetic CLEAR delta
1997        let mut iterator =
1998            BatchedDeltasStreamIterator::new(&temp_file, 10, Some(4), Some(1), None, Some(1))
1999                .unwrap();
2000        iterator.fill_pending_batches().transpose().unwrap();
2001        assert_eq!(iterator.pending_batches.len(), 1);
2002        assert_eq!(iterator.pending_batches[0].len(), 1);
2003        assert_eq!(iterator.pending_batches[0][0].action, BookAction::Clear);
2004
2005        // No limit should return all batches (first batch starts with CLEAR)
2006        let mut iterator =
2007            BatchedDeltasStreamIterator::new(&temp_file, 10, Some(4), Some(1), None, None).unwrap();
2008        iterator.fill_pending_batches().transpose().unwrap();
2009        assert_eq!(iterator.pending_batches.len(), 5);
2010        assert_eq!(iterator.pending_batches[0].len(), 2);
2011        assert_eq!(iterator.pending_batches[0][0].action, BookAction::Clear);
2012        assert_ne!(iterator.pending_batches[0][1].action, BookAction::Clear);
2013        let total_deltas: usize = iterator
2014            .pending_batches
2015            .iter()
2016            .map(|batch| batch.len())
2017            .sum();
2018        assert_eq!(total_deltas, 6);
2019
2020        std::fs::remove_file(&temp_file).ok();
2021    }
2022
2023    #[cfg(feature = "python")]
2024    #[rstest]
2025    fn test_stream_batched_deltas_groups_messages_by_local_timestamp() {
2026        let filepath = get_test_data_path("csv/deltas_message_boundaries.csv");
2027        let expected = load_deltas(&filepath, Some(1), Some(1), None, None).unwrap();
2028        let mut iterator =
2029            BatchedDeltasStreamIterator::new(&filepath, 100, Some(1), Some(1), None, None).unwrap();
2030
2031        iterator.fill_pending_batches().transpose().unwrap();
2032
2033        assert_eq!(
2034            iterator
2035                .pending_batches
2036                .iter()
2037                .map(Vec::len)
2038                .collect::<Vec<_>>(),
2039            vec![2, 2, 2]
2040        );
2041        assert_eq!(
2042            iterator
2043                .pending_batches
2044                .into_iter()
2045                .flatten()
2046                .collect::<Vec<_>>(),
2047            expected
2048        );
2049    }
2050
2051    #[cfg(feature = "python")]
2052    #[rstest]
2053    fn test_stream_batched_deltas_returns_python_objects() {
2054        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2055binance,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
2056binance,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0";
2057
2058        let temp_file = tempfile::NamedTempFile::new().unwrap();
2059        std::fs::write(temp_file.path(), csv_data).unwrap();
2060        Python::initialize();
2061
2062        let mut stream =
2063            stream_batched_deltas(temp_file.path(), 10, Some(1), Some(1), None, None).unwrap();
2064        let objects = stream.next().unwrap().unwrap();
2065
2066        Python::attach(|py| {
2067            let deltas: Vec<_> = objects
2068                .iter()
2069                .map(|obj| {
2070                    obj.bind(py)
2071                        .cast::<OrderBookDeltas>()
2072                        .unwrap()
2073                        .borrow()
2074                        .clone()
2075                })
2076                .collect();
2077
2078            assert_eq!(deltas.len(), 2);
2079            assert_eq!(deltas[0].deltas.len(), 2);
2080            assert_eq!(deltas[0].deltas[0].action, BookAction::Clear);
2081            assert_eq!(deltas[0].deltas[1].action, BookAction::Add);
2082            assert_eq!(deltas[1].deltas.len(), 1);
2083            assert_eq!(deltas[1].deltas[0].action, BookAction::Update);
2084        });
2085        assert!(stream.next().is_none());
2086    }
2087
2088    #[cfg(feature = "python")]
2089    #[rstest]
2090    pub fn test_stream_batched_deltas_with_mid_snapshot_inserts_clear() {
2091        // CSV with:
2092        // - Initial snapshot (is_snapshot=true) at start
2093        // - Some deltas (is_snapshot=false)
2094        // - Mid-day snapshot (is_snapshot=true) - should trigger CLEAR
2095        // - Back to deltas (is_snapshot=false)
2096        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2097binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2098binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
2099binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
2100binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
2101binance-futures,BTCUSDT,1640995300000000,1640995300100000,true,bid,50100.0,3.0
2102binance-futures,BTCUSDT,1640995300000000,1640995300100000,true,ask,50101.0,4.0
2103binance-futures,BTCUSDT,1640995301000000,1640995301100000,false,bid,50099.0,1.0";
2104
2105        let temp_file = std::env::temp_dir().join("test_stream_batched_mid_snapshot.csv");
2106        std::fs::write(&temp_file, csv_data).unwrap();
2107
2108        let mut iterator =
2109            BatchedDeltasStreamIterator::new(&temp_file, 100, Some(1), Some(1), None, None)
2110                .unwrap();
2111        iterator.fill_pending_batches().transpose().unwrap();
2112
2113        let all_deltas: Vec<_> = iterator.pending_batches.iter().flatten().collect();
2114        let clear_count = all_deltas
2115            .iter()
2116            .filter(|d| d.action == BookAction::Clear)
2117            .count();
2118
2119        // Should have 2 CLEAR deltas: initial snapshot + mid-day snapshot
2120        assert_eq!(
2121            clear_count, 2,
2122            "Expected 2 CLEAR deltas (initial + mid-day snapshot), found {clear_count}"
2123        );
2124
2125        // Verify CLEAR positions:
2126        // 0=CLEAR, 1=Add, 2=Add, 3=Update, 4=Update, 5=CLEAR, 6=Add, 7=Add, 8=Update
2127        assert_eq!(all_deltas[0].action, BookAction::Clear);
2128        assert_eq!(all_deltas[5].action, BookAction::Clear);
2129
2130        // CLEAR deltas should NOT have F_LAST when followed by same-timestamp deltas
2131        assert_eq!(
2132            all_deltas[0].flags & RecordFlag::F_LAST as u8,
2133            0,
2134            "CLEAR at index 0 should not have F_LAST flag"
2135        );
2136        assert_eq!(
2137            all_deltas[5].flags & RecordFlag::F_LAST as u8,
2138            0,
2139            "CLEAR at index 5 should not have F_LAST flag"
2140        );
2141
2142        std::fs::remove_file(&temp_file).ok();
2143    }
2144
2145    #[cfg(feature = "python")]
2146    #[rstest]
2147    fn test_stream_batched_deltas_skips_rows_before_first_snapshot() {
2148        // Two leading rows are pre-snapshot orphans and must be skipped, see
2149        // https://docs.tardis.dev/faq/order-books
2150        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2151binance-futures,BTCUSDT,1,1,false,bid,99.0,1.0
2152binance-futures,BTCUSDT,2,2,false,ask,101.0,2.0
2153binance-futures,BTCUSDT,3,3,true,bid,100.0,5.0
2154binance-futures,BTCUSDT,3,3,true,ask,100.5,6.0
2155binance-futures,BTCUSDT,4,4,false,bid,100.0,7.0";
2156
2157        let temp_file =
2158            std::env::temp_dir().join("test_stream_batched_deltas_pre_snapshot_orphans.csv");
2159        std::fs::write(&temp_file, csv_data).unwrap();
2160
2161        let mut iterator =
2162            BatchedDeltasStreamIterator::new(&temp_file, 100, Some(1), Some(0), None, None)
2163                .unwrap();
2164        iterator.fill_pending_batches().transpose().unwrap();
2165
2166        let all_deltas: Vec<_> = iterator.pending_batches.iter().flatten().collect();
2167
2168        // The 2 pre-snapshot rows are skipped entirely: 1 CLEAR + 2 snapshot Adds + 1 Update.
2169        assert_eq!(all_deltas.len(), 4);
2170        assert_eq!(all_deltas[0].action, BookAction::Clear);
2171        assert_eq!(all_deltas[1].action, BookAction::Add);
2172        assert_eq!(all_deltas[1].order.price, Price::from("100.0"));
2173        assert_eq!(all_deltas[2].action, BookAction::Add);
2174        assert_eq!(all_deltas[2].order.price, Price::from("100.5"));
2175        assert_eq!(all_deltas[3].action, BookAction::Update);
2176        assert_eq!(all_deltas[3].order.price, Price::from("100.0"));
2177
2178        std::fs::remove_file(&temp_file).ok();
2179    }
2180
2181    #[cfg(feature = "python")]
2182    #[rstest]
2183    pub fn test_stream_batched_deltas_with_consecutive_snapshots_inserts_clear() {
2184        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2185hyperliquid,BTC,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2186hyperliquid,BTC,1640995200000001,1640995200100000,true,ask,50001.0,2.0
2187hyperliquid,BTC,1640995201000000,1640995201100000,true,bid,49990.0,3.0
2188hyperliquid,BTC,1640995201000001,1640995201100000,true,ask,49991.0,4.0";
2189
2190        let temp_file = std::env::temp_dir().join("test_stream_batched_consecutive_snapshots.csv");
2191        std::fs::write(&temp_file, csv_data).unwrap();
2192
2193        let mut iterator =
2194            BatchedDeltasStreamIterator::new(&temp_file, 100, Some(1), Some(1), None, None)
2195                .unwrap();
2196        iterator.fill_pending_batches().transpose().unwrap();
2197
2198        let all_deltas: Vec<_> = iterator.pending_batches.iter().flatten().collect();
2199        let clear_count = all_deltas
2200            .iter()
2201            .filter(|d| d.action == BookAction::Clear)
2202            .count();
2203
2204        assert_eq!(clear_count, 2);
2205        assert_eq!(all_deltas[0].action, BookAction::Clear);
2206        assert_eq!(all_deltas[3].action, BookAction::Clear);
2207        assert_eq!(
2208            all_deltas[2].flags & RecordFlag::F_LAST as u8,
2209            RecordFlag::F_LAST as u8
2210        );
2211        assert_eq!(all_deltas[3].flags & RecordFlag::F_LAST as u8, 0);
2212        assert_eq!(
2213            all_deltas
2214                .iter()
2215                .map(|delta| (delta.action, delta.flags, delta.ts_event, delta.ts_init))
2216                .collect::<Vec<_>>(),
2217            vec![
2218                (
2219                    BookAction::Clear,
2220                    RecordFlag::F_SNAPSHOT as u8,
2221                    UnixNanos::from(1_640_995_200_000_000_000),
2222                    UnixNanos::from(1_640_995_200_100_000_000),
2223                ),
2224                (
2225                    BookAction::Add,
2226                    0,
2227                    UnixNanos::from(1_640_995_200_000_000_000),
2228                    UnixNanos::from(1_640_995_200_100_000_000),
2229                ),
2230                (
2231                    BookAction::Add,
2232                    RecordFlag::F_LAST as u8,
2233                    UnixNanos::from(1_640_995_200_000_001_000),
2234                    UnixNanos::from(1_640_995_200_100_000_000),
2235                ),
2236                (
2237                    BookAction::Clear,
2238                    RecordFlag::F_SNAPSHOT as u8,
2239                    UnixNanos::from(1_640_995_201_000_000_000),
2240                    UnixNanos::from(1_640_995_201_100_000_000),
2241                ),
2242                (
2243                    BookAction::Add,
2244                    0,
2245                    UnixNanos::from(1_640_995_201_000_000_000),
2246                    UnixNanos::from(1_640_995_201_100_000_000),
2247                ),
2248                (
2249                    BookAction::Add,
2250                    RecordFlag::F_LAST as u8,
2251                    UnixNanos::from(1_640_995_201_000_001_000),
2252                    UnixNanos::from(1_640_995_201_100_000_000),
2253                ),
2254            ]
2255        );
2256
2257        std::fs::remove_file(&temp_file).ok();
2258    }
2259
2260    #[cfg(feature = "python")]
2261    #[rstest]
2262    pub fn test_stream_batched_deltas_limit_includes_clear() {
2263        // Test that limit counts total emitted deltas (including CLEARs)
2264        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2265binance,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2266binance,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
2267binance,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
2268binance,BTCUSDT,1640995203000000,1640995203100000,false,bid,49998.0,0.5
2269binance,BTCUSDT,1640995204000000,1640995204100000,false,ask,50003.0,1.0";
2270
2271        let temp_file = std::env::temp_dir().join("test_stream_batched_limit_includes_clear.csv");
2272        std::fs::write(&temp_file, csv_data).unwrap();
2273
2274        let mut iterator =
2275            BatchedDeltasStreamIterator::new(&temp_file, 100, Some(1), Some(1), None, Some(4))
2276                .unwrap();
2277        iterator.fill_pending_batches().transpose().unwrap();
2278
2279        let all_deltas: Vec<_> = iterator.pending_batches.iter().flatten().collect();
2280
2281        // limit=4 should get exactly 4 deltas: 1 CLEAR + 3 data deltas
2282        assert_eq!(all_deltas.len(), 4);
2283        assert_eq!(all_deltas[0].action, BookAction::Clear);
2284        assert_eq!(all_deltas[1].action, BookAction::Add);
2285        assert_eq!(all_deltas[2].action, BookAction::Update);
2286        assert_eq!(all_deltas[3].action, BookAction::Update);
2287
2288        std::fs::remove_file(&temp_file).ok();
2289    }
2290
2291    #[cfg(feature = "python")]
2292    #[rstest]
2293    pub fn test_stream_batched_deltas_limit_sets_f_last() {
2294        // Test that F_LAST is set on the final delta when limit is reached
2295        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2296binance,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2297binance,BTCUSDT,1640995201000000,1640995201100000,false,ask,50001.0,2.0
2298binance,BTCUSDT,1640995202000000,1640995202100000,false,bid,49999.0,0.5
2299binance,BTCUSDT,1640995203000000,1640995203100000,false,ask,50002.0,1.5";
2300
2301        let temp_file = std::env::temp_dir().join("test_stream_batched_limit_f_last.csv");
2302        std::fs::write(&temp_file, csv_data).unwrap();
2303
2304        // limit=3 should get 3 deltas with F_LAST on the last one
2305        let mut iterator =
2306            BatchedDeltasStreamIterator::new(&temp_file, 100, Some(1), Some(1), None, Some(3))
2307                .unwrap();
2308        iterator.fill_pending_batches().transpose().unwrap();
2309
2310        let all_deltas: Vec<_> = iterator.pending_batches.iter().flatten().collect();
2311
2312        assert_eq!(all_deltas.len(), 3);
2313        assert_eq!(
2314            all_deltas[2].flags & RecordFlag::F_LAST as u8,
2315            RecordFlag::F_LAST as u8,
2316            "Final delta should have F_LAST flag when limit is reached"
2317        );
2318
2319        std::fs::remove_file(&temp_file).ok();
2320    }
2321
2322    #[cfg(feature = "python")]
2323    #[rstest]
2324    pub fn test_stream_batched_deltas_snapshot_batch_flags() {
2325        // Test that CLEAR is first in batch and only the last delta has F_LAST
2326        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2327binance,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2328binance,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
2329binance,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5";
2330
2331        let temp_file = std::env::temp_dir().join("test_stream_batched_snapshot_batch_flags.csv");
2332        std::fs::write(&temp_file, csv_data).unwrap();
2333
2334        let mut iterator =
2335            BatchedDeltasStreamIterator::new(&temp_file, 100, Some(1), Some(1), None, None)
2336                .unwrap();
2337        iterator.fill_pending_batches().transpose().unwrap();
2338
2339        assert_eq!(iterator.pending_batches.len(), 2);
2340        let first_batch = &iterator.pending_batches[0];
2341
2342        // First batch contains CLEAR + 2 snapshot deltas
2343        assert_eq!(first_batch.len(), 3);
2344        assert_eq!(first_batch[0].action, BookAction::Clear);
2345        assert_eq!(first_batch[0].flags & RecordFlag::F_LAST as u8, 0);
2346        assert_eq!(first_batch[1].flags & RecordFlag::F_LAST as u8, 0);
2347        assert_eq!(
2348            first_batch[2].flags & RecordFlag::F_LAST as u8,
2349            RecordFlag::F_LAST as u8
2350        );
2351
2352        // Second batch should have F_LAST set (end of file)
2353        assert_eq!(iterator.pending_batches[1].len(), 1);
2354        assert_eq!(
2355            iterator.pending_batches[1][0].flags & RecordFlag::F_LAST as u8,
2356            RecordFlag::F_LAST as u8
2357        );
2358
2359        std::fs::remove_file(&temp_file).ok();
2360    }
2361
2362    #[rstest]
2363    pub fn test_stream_quotes_chunked() {
2364        let csv_data =
2365            "exchange,symbol,timestamp,local_timestamp,ask_amount,ask_price,bid_price,bid_amount
2366binance,BTCUSDT,1640995200000000,1640995200100000,1.0,50000.0,49999.0,1.5
2367binance,BTCUSDT,1640995201000000,1640995201100000,2.0,50000.5,49999.5,2.5
2368binance,BTCUSDT,1640995202000000,1640995202100000,1.5,50000.12,49999.12,1.8
2369binance,BTCUSDT,1640995203000000,1640995203100000,3.0,50000.123,49999.123,3.2
2370binance,BTCUSDT,1640995204000000,1640995204100000,0.5,50000.1234,49999.1234,0.8";
2371
2372        let temp_file = std::env::temp_dir().join("test_stream_quotes.csv");
2373        std::fs::write(&temp_file, csv_data).unwrap();
2374
2375        let stream = stream_quotes(&temp_file, 2, Some(4), Some(1), None, None).unwrap();
2376        let chunks: Vec<_> = stream.collect();
2377
2378        assert_eq!(chunks.len(), 3);
2379
2380        let chunk1 = chunks[0].as_ref().unwrap();
2381        assert_eq!(chunk1.len(), 2);
2382        assert_eq!(chunk1[0].bid_price.precision, 4);
2383        assert_eq!(chunk1[1].bid_price.precision, 4);
2384
2385        let chunk2 = chunks[1].as_ref().unwrap();
2386        assert_eq!(chunk2.len(), 2);
2387        assert_eq!(chunk2[0].bid_price.precision, 4);
2388        assert_eq!(chunk2[1].bid_price.precision, 4);
2389
2390        let chunk3 = chunks[2].as_ref().unwrap();
2391        assert_eq!(chunk3.len(), 1);
2392        assert_eq!(chunk3[0].bid_price.precision, 4);
2393
2394        let total_quotes: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
2395        assert_eq!(total_quotes, 5);
2396
2397        std::fs::remove_file(&temp_file).ok();
2398    }
2399
2400    #[rstest]
2401    pub fn test_stream_options_chain_filters_and_chunks() {
2402        let filepath = get_test_data_path("options_chain.csv");
2403        let stream = stream_options_chain(
2404            filepath,
2405            1,
2406            Some(vec!["ETH-".to_string()]),
2407            None,
2408            None,
2409            None,
2410        )
2411        .unwrap();
2412        let chunks: Vec<_> = stream.collect();
2413
2414        assert_eq!(chunks.len(), 2);
2415
2416        let first_chunk = chunks[0].as_ref().unwrap();
2417        assert_eq!(first_chunk.len(), 2);
2418        let Data::Quote(quote) = &first_chunk[0] else {
2419            panic!("Expected first data item to be Quote");
2420        };
2421        let Data::OptionGreeks(greeks) = &first_chunk[1] else {
2422            panic!("Expected second data item to be OptionGreeks");
2423        };
2424
2425        assert_eq!(
2426            quote.instrument_id,
2427            InstrumentId::from("ETH-9JUN20-250-P.DERIBIT")
2428        );
2429        assert_eq!(quote.bid_price, Price::from("0.12345"));
2430        assert_eq!(quote.bid_size, Quantity::from("0.123456"));
2431        assert_eq!(quote.bid_price.precision, 5);
2432        assert_eq!(quote.bid_size.precision, 6);
2433        assert_eq!(greeks.instrument_id, quote.instrument_id);
2434
2435        let second_chunk = chunks[1].as_ref().unwrap();
2436        assert_eq!(second_chunk.len(), 1);
2437        assert!(matches!(second_chunk[0], Data::OptionGreeks(_)));
2438    }
2439
2440    #[rstest]
2441    pub fn test_stream_trades_chunked() {
2442        let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
2443binance,BTCUSDT,1640995200000000,1640995200100000,trade1,buy,50000.0,1.0
2444binance,BTCUSDT,1640995201000000,1640995201100000,trade2,sell,49999.5,2.0
2445binance,BTCUSDT,1640995202000000,1640995202100000,trade3,buy,50000.12,1.5
2446binance,BTCUSDT,1640995203000000,1640995203100000,trade4,sell,49999.123,3.0
2447binance,BTCUSDT,1640995204000000,1640995204100000,trade5,buy,50000.1234,0.5";
2448
2449        let temp_file = std::env::temp_dir().join("test_stream_trades.csv");
2450        std::fs::write(&temp_file, csv_data).unwrap();
2451
2452        let stream = stream_trades(&temp_file, 3, Some(4), Some(1), None, None).unwrap();
2453        let chunks: Vec<_> = stream.collect();
2454
2455        assert_eq!(chunks.len(), 2);
2456
2457        let chunk1 = chunks[0].as_ref().unwrap();
2458        assert_eq!(chunk1.len(), 3);
2459        assert_eq!(chunk1[0].price.precision, 4);
2460        assert_eq!(chunk1[1].price.precision, 4);
2461        assert_eq!(chunk1[2].price.precision, 4);
2462
2463        let chunk2 = chunks[1].as_ref().unwrap();
2464        assert_eq!(chunk2.len(), 2);
2465        assert_eq!(chunk2[0].price.precision, 4);
2466        assert_eq!(chunk2[1].price.precision, 4);
2467
2468        assert_eq!(chunk1[0].aggressor_side, AggressorSide::Buy);
2469        assert_eq!(chunk1[1].aggressor_side, AggressorSide::Sell);
2470
2471        let total_trades: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
2472        assert_eq!(total_trades, 5);
2473
2474        std::fs::remove_file(&temp_file).ok();
2475    }
2476
2477    #[rstest]
2478    pub fn test_stream_trades_with_zero_sized_trade() {
2479        // Test CSV data with one zero-sized trade that should be skipped
2480        let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
2481binance,BTCUSDT,1640995200000000,1640995200100000,trade1,buy,50000.0,1.0
2482binance,BTCUSDT,1640995201000000,1640995201100000,trade2,sell,49999.5,0.0
2483binance,BTCUSDT,1640995202000000,1640995202100000,trade3,buy,50000.12,1.5
2484binance,BTCUSDT,1640995203000000,1640995203100000,trade4,sell,49999.123,3.0";
2485
2486        let temp_file = std::env::temp_dir().join("test_stream_trades_zero_size.csv");
2487        std::fs::write(&temp_file, csv_data).unwrap();
2488
2489        let stream = stream_trades(&temp_file, 3, Some(4), Some(1), None, None).unwrap();
2490        let chunks: Vec<_> = stream.collect();
2491
2492        // Should have 1 chunk with 3 valid trades (zero-sized trade skipped)
2493        assert_eq!(chunks.len(), 1);
2494
2495        let chunk1 = chunks[0].as_ref().unwrap();
2496        assert_eq!(chunk1.len(), 3);
2497
2498        // Verify the trades are the correct ones (not the zero-sized one)
2499        assert_eq!(chunk1[0].size, Quantity::from("1.0"));
2500        assert_eq!(chunk1[1].size, Quantity::from("1.5"));
2501        assert_eq!(chunk1[2].size, Quantity::from("3.0"));
2502
2503        // Verify trade IDs to confirm correct trades were loaded
2504        assert_eq!(chunk1[0].trade_id, TradeId::new("trade1"));
2505        assert_eq!(chunk1[1].trade_id, TradeId::new("trade3"));
2506        assert_eq!(chunk1[2].trade_id, TradeId::new("trade4"));
2507
2508        std::fs::remove_file(&temp_file).ok();
2509    }
2510
2511    #[rstest]
2512    pub fn test_stream_depth_from_snapshot5_chunked() {
2513        let csv_data = "exchange,symbol,timestamp,local_timestamp,asks[0].price,asks[0].amount,bids[0].price,bids[0].amount,asks[1].price,asks[1].amount,bids[1].price,bids[1].amount,asks[2].price,asks[2].amount,bids[2].price,bids[2].amount,asks[3].price,asks[3].amount,bids[3].price,bids[3].amount,asks[4].price,asks[4].amount,bids[4].price,bids[4].amount
2514binance,BTCUSDT,1640995200000000,1640995200100000,50001.0,1.0,49999.0,1.5,50002.0,2.0,49998.0,2.5,50003.0,3.0,49997.0,3.5,50004.0,4.0,49996.0,4.5,50005.0,5.0,49995.0,5.5
2515binance,BTCUSDT,1640995201000000,1640995201100000,50001.5,1.1,49999.5,1.6,50002.5,2.1,49998.5,2.6,50003.5,3.1,49997.5,3.6,50004.5,4.1,49996.5,4.6,50005.5,5.1,49995.5,5.6
2516binance,BTCUSDT,1640995202000000,1640995202100000,50001.12,1.12,49999.12,1.62,50002.12,2.12,49998.12,2.62,50003.12,3.12,49997.12,3.62,50004.12,4.12,49996.12,4.62,50005.12,5.12,49995.12,5.62";
2517
2518        // Write to temporary file
2519        let temp_file = std::env::temp_dir().join("test_stream_depth_snapshot5.csv");
2520        std::fs::write(&temp_file, csv_data).unwrap();
2521
2522        // Stream with chunk size of 2
2523        let stream = stream_depth_from_snapshot5(&temp_file, 2, None, None, None, None).unwrap();
2524        let chunks: Vec<_> = stream.collect();
2525
2526        // Should have 2 chunks: [2 items, 1 item]
2527        assert_eq!(chunks.len(), 2);
2528
2529        // First chunk: 2 depth snapshots
2530        let chunk1 = chunks[0].as_ref().unwrap();
2531        assert_eq!(chunk1.len(), 2);
2532
2533        // Second chunk: 1 depth snapshot
2534        let chunk2 = chunks[1].as_ref().unwrap();
2535        assert_eq!(chunk2.len(), 1);
2536
2537        let first_depth = &chunk1[0];
2538        let expected_bids = [
2539            ("49999.0", "1.5"),
2540            ("49998.0", "2.5"),
2541            ("49997.0", "3.5"),
2542            ("49996.0", "4.5"),
2543            ("49995.0", "5.5"),
2544        ];
2545        let expected_asks = [
2546            ("50001.0", "1.0"),
2547            ("50002.0", "2.0"),
2548            ("50003.0", "3.0"),
2549            ("50004.0", "4.0"),
2550            ("50005.0", "5.0"),
2551        ];
2552
2553        assert_eq!(
2554            first_depth.instrument_id,
2555            InstrumentId::from("BTCUSDT.BINANCE")
2556        );
2557        assert_eq!(first_depth.bids.len(), expected_bids.len());
2558        assert_eq!(first_depth.asks.len(), expected_asks.len());
2559        assert_eq!(first_depth.bid_counts.as_slice(), &[1; 5]);
2560        assert_eq!(first_depth.ask_counts.as_slice(), &[1; 5]);
2561        for (order, (price, size)) in first_depth.bids.iter().zip(expected_bids) {
2562            assert_eq!(order.side, Some(OrderSide::Buy));
2563            assert_eq!(order.price, Price::from(price));
2564            assert_eq!(order.size, Quantity::from(size));
2565            assert_eq!(order.order_id, 0);
2566        }
2567
2568        for (order, (price, size)) in first_depth.asks.iter().zip(expected_asks) {
2569            assert_eq!(order.side, Some(OrderSide::Sell));
2570            assert_eq!(order.price, Price::from(price));
2571            assert_eq!(order.size, Quantity::from(size));
2572            assert_eq!(order.order_id, 0);
2573        }
2574        assert_eq!(first_depth.flags, RecordFlag::F_SNAPSHOT as u8);
2575        assert_eq!(first_depth.sequence, 0);
2576        assert_eq!(
2577            first_depth.ts_event,
2578            UnixNanos::from(1_640_995_200_000_000_000)
2579        );
2580        assert_eq!(
2581            first_depth.ts_init,
2582            UnixNanos::from(1_640_995_200_100_000_000)
2583        );
2584
2585        // Verify total count
2586        let total_depths: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
2587        assert_eq!(total_depths, 3);
2588
2589        // Clean up
2590        std::fs::remove_file(&temp_file).ok();
2591    }
2592
2593    #[rstest]
2594    pub fn test_stream_depth_from_snapshot25_chunked() {
2595        let expected_bids = [
2596            ("49999.00", "1.5"),
2597            ("49998.99", "2.5"),
2598            ("49998.98", "3.5"),
2599            ("49998.97", "4.5"),
2600            ("49998.96", "5.5"),
2601        ];
2602        let expected_asks = [
2603            ("50000.00", "1.0"),
2604            ("50000.01", "2.0"),
2605            ("50000.02", "3.0"),
2606            ("50000.03", "4.0"),
2607            ("50000.04", "5.0"),
2608        ];
2609        let mut headers = vec![
2610            "exchange".to_string(),
2611            "symbol".to_string(),
2612            "timestamp".to_string(),
2613            "local_timestamp".to_string(),
2614        ];
2615        let mut row = vec!["binance", "BTCUSDT", "1640995200000000", "1640995200100000"];
2616
2617        // CSV records are decoded positionally in ask/bid order for each level
2618        for i in 0..25 {
2619            headers.extend([
2620                format!("asks[{i}].price"),
2621                format!("asks[{i}].amount"),
2622                format!("bids[{i}].price"),
2623                format!("bids[{i}].amount"),
2624            ]);
2625            let (ask_price, ask_size) = expected_asks.get(i).copied().unwrap_or(("", ""));
2626            let (bid_price, bid_size) = expected_bids.get(i).copied().unwrap_or(("", ""));
2627            row.extend([ask_price, ask_size, bid_price, bid_size]);
2628        }
2629        let csv_data = format!("{}\n{}", headers.join(","), row.join(","));
2630        let temp_file = std::env::temp_dir().join("test_stream_depth_snapshot25.csv");
2631        std::fs::write(&temp_file, csv_data).unwrap();
2632
2633        let stream = stream_depth_from_snapshot25(&temp_file, 1, None, None, None, None).unwrap();
2634        let chunks: Vec<_> = stream.collect();
2635
2636        assert_eq!(chunks.len(), 1);
2637        let chunk = chunks[0].as_ref().unwrap();
2638        assert_eq!(chunk.len(), 1);
2639        let depth = &chunk[0];
2640        assert_eq!(depth.instrument_id, InstrumentId::from("BTCUSDT.BINANCE"));
2641        assert_eq!(depth.bids.len(), expected_bids.len());
2642        assert_eq!(depth.asks.len(), expected_asks.len());
2643        assert_eq!(depth.bid_counts.as_slice(), &[1; 5]);
2644        assert_eq!(depth.ask_counts.as_slice(), &[1; 5]);
2645        for (order, (price, size)) in depth.bids.iter().zip(expected_bids) {
2646            assert_eq!(order.side, Some(OrderSide::Buy));
2647            assert_eq!(order.price, Price::from(price));
2648            assert_eq!(order.size, Quantity::from(size));
2649            assert_eq!(order.order_id, 0);
2650        }
2651
2652        for (order, (price, size)) in depth.asks.iter().zip(expected_asks) {
2653            assert_eq!(order.side, Some(OrderSide::Sell));
2654            assert_eq!(order.price, Price::from(price));
2655            assert_eq!(order.size, Quantity::from(size));
2656            assert_eq!(order.order_id, 0);
2657        }
2658        assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
2659        assert_eq!(depth.sequence, 0);
2660        assert_eq!(depth.ts_event, UnixNanos::from(1_640_995_200_000_000_000));
2661        assert_eq!(depth.ts_init, UnixNanos::from(1_640_995_200_100_000_000));
2662
2663        std::fs::remove_file(&temp_file).ok();
2664    }
2665
2666    #[rstest]
2667    pub fn test_stream_depth_from_snapshot25_fills_all_levels() {
2668        // Generate 25 distinct levels per side with integer-cent prices
2669        let expected_bids: Vec<(String, String)> = (0..25)
2670            .map(|i: i32| {
2671                let cents = 4_999_900 - i;
2672                (
2673                    format!("{}.{:02}", cents / 100, cents % 100),
2674                    format!("{}.5", i + 1),
2675                )
2676            })
2677            .collect();
2678        let expected_asks: Vec<(String, String)> = (0..25)
2679            .map(|i: i32| {
2680                let cents = 5_000_000 + i;
2681                (
2682                    format!("{}.{:02}", cents / 100, cents % 100),
2683                    format!("{}.0", i + 1),
2684                )
2685            })
2686            .collect();
2687
2688        let mut headers = vec![
2689            "exchange".to_string(),
2690            "symbol".to_string(),
2691            "timestamp".to_string(),
2692            "local_timestamp".to_string(),
2693        ];
2694        let mut row = vec![
2695            "binance".to_string(),
2696            "BTCUSDT".to_string(),
2697            "1640995200000000".to_string(),
2698            "1640995200100000".to_string(),
2699        ];
2700
2701        // CSV records are decoded positionally in ask/bid order for each level
2702        for i in 0..25 {
2703            headers.extend([
2704                format!("asks[{i}].price"),
2705                format!("asks[{i}].amount"),
2706                format!("bids[{i}].price"),
2707                format!("bids[{i}].amount"),
2708            ]);
2709            let (ask_price, ask_size) = &expected_asks[i];
2710            let (bid_price, bid_size) = &expected_bids[i];
2711            row.extend([
2712                ask_price.clone(),
2713                ask_size.clone(),
2714                bid_price.clone(),
2715                bid_size.clone(),
2716            ]);
2717        }
2718        let csv_data = format!("{}\n{}", headers.join(","), row.join(","));
2719        let temp_file = std::env::temp_dir().join("test_stream_depth_snapshot25_full.csv");
2720        std::fs::write(&temp_file, csv_data).unwrap();
2721
2722        let stream = stream_depth_from_snapshot25(&temp_file, 1, None, None, None, None).unwrap();
2723        let chunks: Vec<_> = stream.collect();
2724
2725        assert_eq!(chunks.len(), 1);
2726        let chunk = chunks[0].as_ref().unwrap();
2727        assert_eq!(chunk.len(), 1);
2728        let depth = &chunk[0];
2729        assert_eq!(depth.instrument_id, InstrumentId::from("BTCUSDT.BINANCE"));
2730        assert_eq!(depth.bids.len(), 25);
2731        assert_eq!(depth.asks.len(), 25);
2732        assert_eq!(depth.bid_counts.as_slice(), &[1; 25]);
2733        assert_eq!(depth.ask_counts.as_slice(), &[1; 25]);
2734
2735        for (order, (price, size)) in depth.bids.iter().zip(&expected_bids) {
2736            assert_eq!(order.side, Some(OrderSide::Buy));
2737            assert_eq!(order.price, Price::from(price.as_str()));
2738            assert_eq!(order.size, Quantity::from(size.as_str()));
2739            assert_eq!(order.order_id, 0);
2740        }
2741
2742        for (order, (price, size)) in depth.asks.iter().zip(&expected_asks) {
2743            assert_eq!(order.side, Some(OrderSide::Sell));
2744            assert_eq!(order.price, Price::from(price.as_str()));
2745            assert_eq!(order.size, Quantity::from(size.as_str()));
2746            assert_eq!(order.order_id, 0);
2747        }
2748
2749        // Deepest levels prove levels beyond the first 10 are retained
2750        assert_eq!(depth.bids[24].price, Price::from("49998.76"));
2751        assert_eq!(depth.asks[24].price, Price::from("50000.24"));
2752        assert_eq!(depth.flags, RecordFlag::F_SNAPSHOT as u8);
2753        assert_eq!(depth.sequence, 0);
2754
2755        std::fs::remove_file(&temp_file).ok();
2756    }
2757
2758    #[rstest]
2759    pub fn test_stream_error_handling() {
2760        // Test with non-existent file
2761        let non_existent = std::path::Path::new("does_not_exist.csv");
2762
2763        let result = stream_deltas(non_existent, 10, None, None, None, None);
2764        assert!(result.is_err());
2765
2766        let result = stream_quotes(non_existent, 10, None, None, None, None);
2767        assert!(result.is_err());
2768
2769        let result = stream_trades(non_existent, 10, None, None, None, None);
2770        assert!(result.is_err());
2771
2772        let result = stream_depth_from_snapshot5(non_existent, 10, None, None, None, None);
2773        assert!(result.is_err());
2774
2775        let result = stream_depth_from_snapshot25(non_existent, 10, None, None, None, None);
2776        assert!(result.is_err());
2777    }
2778
2779    #[rstest]
2780    pub fn test_stream_empty_file() {
2781        // Test with empty CSV file
2782        let temp_file = std::env::temp_dir().join("test_empty.csv");
2783        std::fs::write(&temp_file, "").unwrap();
2784
2785        let stream = stream_deltas(&temp_file, 10, None, None, None, None).unwrap();
2786        assert_eq!(stream.count(), 0);
2787
2788        // Clean up
2789        std::fs::remove_file(&temp_file).ok();
2790    }
2791
2792    #[rstest]
2793    pub fn test_stream_precision_consistency() {
2794        // Test that streaming produces same results as bulk loading for precision inference
2795        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2796binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
2797binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0
2798binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50000.12,1.5
2799binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49999.123,3.0";
2800
2801        let temp_file = std::env::temp_dir().join("test_precision_consistency.csv");
2802        std::fs::write(&temp_file, csv_data).unwrap();
2803
2804        // Load all at once
2805        let bulk_deltas = load_deltas(&temp_file, None, None, None, None).unwrap();
2806
2807        // Stream in chunks and collect
2808        let stream = stream_deltas(&temp_file, 2, None, None, None, None).unwrap();
2809        let streamed_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
2810
2811        // Should have same number of deltas
2812        assert_eq!(bulk_deltas.len(), streamed_deltas.len());
2813
2814        // Compare key properties (precision inference will be different due to chunking)
2815        for (bulk, streamed) in bulk_deltas.iter().zip(streamed_deltas.iter()) {
2816            assert_eq!(bulk.instrument_id, streamed.instrument_id);
2817            assert_eq!(bulk.action, streamed.action);
2818            assert_eq!(bulk.order.side, streamed.order.side);
2819            assert_eq!(bulk.ts_event, streamed.ts_event);
2820            assert_eq!(bulk.ts_init, streamed.ts_init);
2821            // Note: precision may differ between bulk and streaming due to chunk boundaries
2822        }
2823
2824        // Clean up
2825        std::fs::remove_file(&temp_file).ok();
2826    }
2827
2828    #[rstest]
2829    pub fn test_stream_trades_from_local_file() {
2830        let filepath = get_test_data_path("csv/trades_1.csv");
2831        let mut stream = stream_trades(filepath, 1, Some(1), Some(0), None, None).unwrap();
2832
2833        let chunk1 = stream.next().unwrap().unwrap();
2834        assert_eq!(chunk1.len(), 1);
2835        assert_eq!(chunk1[0].price, Price::from("8531.5"));
2836
2837        let chunk2 = stream.next().unwrap().unwrap();
2838        assert_eq!(chunk2.len(), 1);
2839        assert_eq!(chunk2[0].size, Quantity::from("1000"));
2840
2841        assert!(stream.next().is_none());
2842    }
2843
2844    #[rstest]
2845    pub fn test_stream_deltas_from_local_file() {
2846        let filepath = get_test_data_path("csv/deltas_1.csv");
2847        let mut stream = stream_deltas(filepath, 1, Some(1), Some(0), None, None).unwrap();
2848
2849        // With chunk_size=1, each delta gets its own chunk
2850        // First chunk: CLEAR
2851        let chunk1 = stream.next().unwrap().unwrap();
2852        assert_eq!(chunk1.len(), 1);
2853        assert_eq!(chunk1[0].action, BookAction::Clear);
2854
2855        // Second chunk: first data delta
2856        let chunk2 = stream.next().unwrap().unwrap();
2857        assert_eq!(chunk2.len(), 1);
2858        assert_eq!(chunk2[0].order.price, Price::from("6421.5"));
2859
2860        // Third chunk: second data delta
2861        let chunk3 = stream.next().unwrap().unwrap();
2862        assert_eq!(chunk3.len(), 1);
2863        assert_eq!(chunk3[0].order.size, Quantity::from("10000"));
2864
2865        assert!(stream.next().is_none());
2866    }
2867
2868    #[rstest]
2869    pub fn test_stream_deltas_with_limit() {
2870        // First row is a snapshot so the file has valid book state to establish (CLEAR + Add
2871        // both count toward `limit`); the remaining rows are plain deltas as before.
2872        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2873binance,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2874binance,BTCUSDT,1640995201000000,1640995201100000,false,ask,50001.0,2.0
2875binance,BTCUSDT,1640995202000000,1640995202100000,false,bid,49999.0,1.5
2876binance,BTCUSDT,1640995203000000,1640995203100000,false,ask,50002.0,3.0
2877binance,BTCUSDT,1640995204000000,1640995204100000,false,bid,49998.0,0.5";
2878
2879        let temp_file = std::env::temp_dir().join("test_stream_deltas_limit.csv");
2880        std::fs::write(&temp_file, csv_data).unwrap();
2881
2882        // Test with limit of 3 records
2883        let stream = stream_deltas(&temp_file, 2, Some(4), Some(1), None, Some(3)).unwrap();
2884        let chunks: Vec<_> = stream.collect();
2885
2886        // Should have 2 chunks: [2 items, 1 item] = 3 total (limited)
2887        assert_eq!(chunks.len(), 2);
2888        let chunk1 = chunks[0].as_ref().unwrap();
2889        assert_eq!(chunk1.len(), 2);
2890        let chunk2 = chunks[1].as_ref().unwrap();
2891        assert_eq!(chunk2.len(), 1);
2892
2893        // Total should be exactly 3 records due to limit
2894        let total_deltas: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
2895        assert_eq!(total_deltas, 3);
2896
2897        std::fs::remove_file(&temp_file).ok();
2898    }
2899
2900    #[rstest]
2901    pub fn test_stream_quotes_with_limit() {
2902        let csv_data =
2903            "exchange,symbol,timestamp,local_timestamp,ask_price,ask_amount,bid_price,bid_amount
2904binance,BTCUSDT,1640995200000000,1640995200100000,50001.0,1.0,50000.0,1.5
2905binance,BTCUSDT,1640995201000000,1640995201100000,50002.0,2.0,49999.0,2.5
2906binance,BTCUSDT,1640995202000000,1640995202100000,50003.0,1.5,49998.0,3.0
2907binance,BTCUSDT,1640995203000000,1640995203100000,50004.0,3.0,49997.0,3.5";
2908
2909        let temp_file = std::env::temp_dir().join("test_stream_quotes_limit.csv");
2910        std::fs::write(&temp_file, csv_data).unwrap();
2911
2912        // Test with limit of 2 records
2913        let stream = stream_quotes(&temp_file, 2, Some(4), Some(1), None, Some(2)).unwrap();
2914        let chunks: Vec<_> = stream.collect();
2915
2916        // Should have 1 chunk with 2 items (limited)
2917        assert_eq!(chunks.len(), 1);
2918        let chunk1 = chunks[0].as_ref().unwrap();
2919        assert_eq!(chunk1.len(), 2);
2920
2921        // Verify we get exactly 2 records
2922        let total_quotes: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
2923        assert_eq!(total_quotes, 2);
2924
2925        std::fs::remove_file(&temp_file).ok();
2926    }
2927
2928    #[rstest]
2929    pub fn test_stream_trades_with_limit() {
2930        let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
2931binance,BTCUSDT,1640995200000000,1640995200100000,trade1,buy,50000.0,1.0
2932binance,BTCUSDT,1640995201000000,1640995201100000,trade2,sell,49999.5,2.0
2933binance,BTCUSDT,1640995202000000,1640995202100000,trade3,buy,50000.12,1.5
2934binance,BTCUSDT,1640995203000000,1640995203100000,trade4,sell,49999.123,3.0
2935binance,BTCUSDT,1640995204000000,1640995204100000,trade5,buy,50000.1234,0.5";
2936
2937        let temp_file = std::env::temp_dir().join("test_stream_trades_limit.csv");
2938        std::fs::write(&temp_file, csv_data).unwrap();
2939
2940        // Test with limit of 3 records
2941        let stream = stream_trades(&temp_file, 2, Some(4), Some(1), None, Some(3)).unwrap();
2942        let chunks: Vec<_> = stream.collect();
2943
2944        // Should have 2 chunks: [2 items, 1 item] = 3 total (limited)
2945        assert_eq!(chunks.len(), 2);
2946        let chunk1 = chunks[0].as_ref().unwrap();
2947        assert_eq!(chunk1.len(), 2);
2948        let chunk2 = chunks[1].as_ref().unwrap();
2949        assert_eq!(chunk2.len(), 1);
2950
2951        // Verify we get exactly 3 records
2952        let total_trades: usize = chunks.iter().map(|c| c.as_ref().unwrap().len()).sum();
2953        assert_eq!(total_trades, 3);
2954
2955        std::fs::remove_file(&temp_file).ok();
2956    }
2957
2958    #[rstest]
2959    pub fn test_depth_invalid_levels_error_at_construction() {
2960        let temp_file = std::env::temp_dir().join("test_depth_invalid_levels.csv");
2961        std::fs::write(&temp_file, "exchange,symbol,timestamp,local_timestamp\n").unwrap();
2962
2963        let result = DepthStreamIterator::new(&temp_file, 10, 10, None, None, None, None);
2964        assert!(result.is_err());
2965        let err_msg = result.err().unwrap().to_string();
2966        assert!(
2967            err_msg.contains("Invalid levels"),
2968            "Error should mention 'Invalid levels': {err_msg}"
2969        );
2970
2971        let result = DepthStreamIterator::new(&temp_file, 10, 3, None, None, None, None);
2972        assert!(result.is_err());
2973
2974        let result = DepthStreamIterator::new(&temp_file, 10, 5, None, None, None, None);
2975        assert!(result.is_ok());
2976
2977        let result = DepthStreamIterator::new(&temp_file, 10, 25, None, None, None, None);
2978        assert!(result.is_ok());
2979
2980        std::fs::remove_file(&temp_file).ok();
2981    }
2982
2983    #[rstest]
2984    pub fn test_stream_deltas_with_mid_snapshot_inserts_clear() {
2985        // CSV with:
2986        // - Initial snapshot (is_snapshot=true) at start
2987        // - Some deltas (is_snapshot=false)
2988        // - Mid-day snapshot (is_snapshot=true) - should trigger CLEAR
2989        // - Back to deltas (is_snapshot=false)
2990        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
2991binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
2992binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
2993binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
2994binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
2995binance-futures,BTCUSDT,1640995300000000,1640995300100000,true,bid,50100.0,3.0
2996binance-futures,BTCUSDT,1640995300000000,1640995300100000,true,ask,50101.0,4.0
2997binance-futures,BTCUSDT,1640995301000000,1640995301100000,false,bid,50099.0,1.0";
2998
2999        let temp_file = std::env::temp_dir().join("test_stream_deltas_mid_snapshot.csv");
3000        std::fs::write(&temp_file, csv_data).unwrap();
3001
3002        let stream = stream_deltas(&temp_file, 100, Some(1), Some(1), None, None).unwrap();
3003        let all_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
3004
3005        let clear_count = all_deltas
3006            .iter()
3007            .filter(|d| d.action == BookAction::Clear)
3008            .count();
3009
3010        // Should have 2 CLEAR deltas: initial snapshot + mid-day snapshot
3011        assert_eq!(
3012            clear_count, 2,
3013            "Expected 2 CLEAR deltas (initial + mid-day snapshot), found {clear_count}"
3014        );
3015
3016        // Verify CLEAR positions:
3017        // 0=CLEAR, 1=Add, 2=Add, 3=Update, 4=Update, 5=CLEAR, 6=Add, 7=Add, 8=Update
3018        assert_eq!(all_deltas[0].action, BookAction::Clear);
3019        assert_eq!(all_deltas[5].action, BookAction::Clear);
3020
3021        // CLEAR deltas should NOT have F_LAST when followed by same-timestamp deltas
3022        assert_eq!(
3023            all_deltas[0].flags & RecordFlag::F_LAST as u8,
3024            0,
3025            "CLEAR at index 0 should not have F_LAST flag"
3026        );
3027        assert_eq!(
3028            all_deltas[5].flags & RecordFlag::F_LAST as u8,
3029            0,
3030            "CLEAR at index 5 should not have F_LAST flag"
3031        );
3032
3033        std::fs::remove_file(&temp_file).ok();
3034    }
3035
3036    #[rstest]
3037    fn test_stream_deltas_skips_rows_before_first_snapshot() {
3038        // Two leading rows are pre-snapshot orphans and must be skipped, see
3039        // https://docs.tardis.dev/faq/order-books
3040        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3041binance-futures,BTCUSDT,1,1,false,bid,99.0,1.0
3042binance-futures,BTCUSDT,2,2,false,ask,101.0,2.0
3043binance-futures,BTCUSDT,3,3,true,bid,100.0,5.0
3044binance-futures,BTCUSDT,3,3,true,ask,100.5,6.0
3045binance-futures,BTCUSDT,4,4,false,bid,100.0,7.0";
3046
3047        let temp_file = std::env::temp_dir().join("test_stream_deltas_pre_snapshot_orphans.csv");
3048        std::fs::write(&temp_file, csv_data).unwrap();
3049
3050        let stream = stream_deltas(&temp_file, 100, Some(1), Some(0), None, None).unwrap();
3051        let all_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
3052
3053        // The 2 pre-snapshot rows are skipped entirely: 1 CLEAR + 2 snapshot Adds + 1 Update.
3054        assert_eq!(all_deltas.len(), 4);
3055        assert_eq!(all_deltas[0].action, BookAction::Clear);
3056        assert_eq!(all_deltas[1].action, BookAction::Add);
3057        assert_eq!(all_deltas[1].order.price, Price::from("100.0"));
3058        assert_eq!(all_deltas[2].action, BookAction::Add);
3059        assert_eq!(all_deltas[2].order.price, Price::from("100.5"));
3060        assert_eq!(all_deltas[3].action, BookAction::Update);
3061        assert_eq!(all_deltas[3].order.price, Price::from("100.0"));
3062
3063        std::fs::remove_file(&temp_file).ok();
3064    }
3065
3066    #[rstest]
3067    pub fn test_stream_deltas_with_consecutive_snapshots_inserts_clear() {
3068        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3069hyperliquid,BTC,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3070hyperliquid,BTC,1640995200000001,1640995200100000,true,ask,50001.0,2.0
3071hyperliquid,BTC,1640995201000000,1640995201100000,true,bid,49990.0,3.0
3072hyperliquid,BTC,1640995201000001,1640995201100000,true,ask,49991.0,4.0";
3073
3074        let temp_file = std::env::temp_dir().join("test_stream_deltas_consecutive_snapshots.csv");
3075        std::fs::write(&temp_file, csv_data).unwrap();
3076
3077        let stream = stream_deltas(&temp_file, 100, Some(1), Some(1), None, None).unwrap();
3078        let all_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
3079        let clear_count = all_deltas
3080            .iter()
3081            .filter(|d| d.action == BookAction::Clear)
3082            .count();
3083
3084        assert_eq!(clear_count, 2);
3085        assert_eq!(all_deltas[0].action, BookAction::Clear);
3086        assert_eq!(all_deltas[3].action, BookAction::Clear);
3087        assert_eq!(
3088            all_deltas[2].flags & RecordFlag::F_LAST as u8,
3089            RecordFlag::F_LAST as u8
3090        );
3091        assert_eq!(all_deltas[3].flags & RecordFlag::F_LAST as u8, 0);
3092        assert_eq!(
3093            all_deltas
3094                .iter()
3095                .map(|delta| (delta.action, delta.flags, delta.ts_event, delta.ts_init))
3096                .collect::<Vec<_>>(),
3097            vec![
3098                (
3099                    BookAction::Clear,
3100                    RecordFlag::F_SNAPSHOT as u8,
3101                    UnixNanos::from(1_640_995_200_000_000_000),
3102                    UnixNanos::from(1_640_995_200_100_000_000),
3103                ),
3104                (
3105                    BookAction::Add,
3106                    0,
3107                    UnixNanos::from(1_640_995_200_000_000_000),
3108                    UnixNanos::from(1_640_995_200_100_000_000),
3109                ),
3110                (
3111                    BookAction::Add,
3112                    RecordFlag::F_LAST as u8,
3113                    UnixNanos::from(1_640_995_200_000_001_000),
3114                    UnixNanos::from(1_640_995_200_100_000_000),
3115                ),
3116                (
3117                    BookAction::Clear,
3118                    RecordFlag::F_SNAPSHOT as u8,
3119                    UnixNanos::from(1_640_995_201_000_000_000),
3120                    UnixNanos::from(1_640_995_201_100_000_000),
3121                ),
3122                (
3123                    BookAction::Add,
3124                    0,
3125                    UnixNanos::from(1_640_995_201_000_000_000),
3126                    UnixNanos::from(1_640_995_201_100_000_000),
3127                ),
3128                (
3129                    BookAction::Add,
3130                    RecordFlag::F_LAST as u8,
3131                    UnixNanos::from(1_640_995_201_000_001_000),
3132                    UnixNanos::from(1_640_995_201_100_000_000),
3133                ),
3134            ]
3135        );
3136
3137        std::fs::remove_file(&temp_file).ok();
3138    }
3139
3140    #[rstest]
3141    pub fn test_stream_deltas_consecutive_snapshots_clear_across_chunk_boundary() {
3142        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3143hyperliquid,BTC,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3144hyperliquid,BTC,1640995200000000,1640995200100000,true,ask,50001.0,2.0
3145hyperliquid,BTC,1640995201000000,1640995201100000,true,bid,49990.0,3.0
3146hyperliquid,BTC,1640995201000000,1640995201100000,true,ask,49991.0,4.0";
3147
3148        let temp_file =
3149            std::env::temp_dir().join("test_stream_deltas_consecutive_snapshots_chunked.csv");
3150        std::fs::write(&temp_file, csv_data).unwrap();
3151
3152        // Chunk size 2 puts the second CLEAR at a chunk boundary and defers its snapshot row,
3153        // which must not insert another CLEAR.
3154        let stream = stream_deltas(&temp_file, 2, Some(1), Some(1), None, None).unwrap();
3155        let all_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
3156        let clear_count = all_deltas
3157            .iter()
3158            .filter(|d| d.action == BookAction::Clear)
3159            .count();
3160
3161        assert_eq!(all_deltas.len(), 6);
3162        assert_eq!(clear_count, 2);
3163        assert_eq!(all_deltas[0].action, BookAction::Clear);
3164        assert_eq!(all_deltas[3].action, BookAction::Clear);
3165        assert_eq!(
3166            all_deltas[2].flags & RecordFlag::F_LAST as u8,
3167            RecordFlag::F_LAST as u8
3168        );
3169        assert_eq!(all_deltas[3].flags & RecordFlag::F_LAST as u8, 0);
3170
3171        std::fs::remove_file(&temp_file).ok();
3172    }
3173
3174    #[rstest]
3175    pub fn test_load_deltas_with_mid_snapshot_inserts_clear() {
3176        let filepath = get_test_data_path("csv/deltas_with_snapshot.csv");
3177        let deltas = load_deltas(&filepath, Some(1), Some(1), None, None).unwrap();
3178
3179        let clear_count = deltas
3180            .iter()
3181            .filter(|d| d.action == BookAction::Clear)
3182            .count();
3183
3184        // Should have 2 CLEAR deltas: initial snapshot + mid-day snapshot
3185        assert_eq!(
3186            clear_count, 2,
3187            "Expected 2 CLEAR deltas (initial + mid-day snapshot), found {clear_count}"
3188        );
3189
3190        assert_eq!(deltas[0].action, BookAction::Clear);
3191
3192        let second_clear_idx = deltas
3193            .iter()
3194            .enumerate()
3195            .filter(|(_, d)| d.action == BookAction::Clear)
3196            .nth(1)
3197            .map(|(i, _)| i)
3198            .expect("Should have second CLEAR");
3199
3200        // 0=CLEAR, 1=Add, 2=Add, 3=Update, 4=Update, 5=Delete, 6=CLEAR
3201        assert_eq!(
3202            second_clear_idx, 6,
3203            "Second CLEAR should be at index 6, found {second_clear_idx}"
3204        );
3205
3206        // CLEAR deltas should NOT have F_LAST when followed by same-timestamp deltas
3207        assert_eq!(
3208            deltas[0].flags & RecordFlag::F_LAST as u8,
3209            0,
3210            "CLEAR at index 0 should not have F_LAST flag"
3211        );
3212        assert_eq!(
3213            deltas[6].flags & RecordFlag::F_LAST as u8,
3214            0,
3215            "CLEAR at index 6 should not have F_LAST flag"
3216        );
3217    }
3218
3219    #[rstest]
3220    fn test_stream_deltas_chunk_size_respects_clear() {
3221        // Test that chunk_size applies to total emitted deltas (including CLEARs)
3222        // With chunk_size=1, a snapshot boundary should emit CLEAR in one chunk
3223        // and the real delta in the next chunk
3224        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3225binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3226binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0";
3227
3228        let temp_file = std::env::temp_dir().join("test_stream_chunk_size_clear.csv");
3229        std::fs::write(&temp_file, csv_data).unwrap();
3230
3231        // chunk_size=1 should produce separate chunks for CLEAR and real deltas
3232        let stream = stream_deltas(&temp_file, 1, Some(1), Some(1), None, None).unwrap();
3233        let chunks: Vec<_> = stream.collect();
3234
3235        // Should have 3 chunks: [CLEAR], [data], [data]
3236        assert_eq!(chunks.len(), 3, "Expected 3 chunks with chunk_size=1");
3237        assert_eq!(chunks[0].as_ref().unwrap().len(), 1);
3238        assert_eq!(chunks[1].as_ref().unwrap().len(), 1);
3239        assert_eq!(chunks[2].as_ref().unwrap().len(), 1);
3240
3241        // First chunk should be CLEAR
3242        assert_eq!(chunks[0].as_ref().unwrap()[0].action, BookAction::Clear);
3243        // Second and third chunks should be data deltas
3244        assert_eq!(chunks[1].as_ref().unwrap()[0].action, BookAction::Add);
3245        assert_eq!(chunks[2].as_ref().unwrap()[0].action, BookAction::Add);
3246
3247        std::fs::remove_file(&temp_file).ok();
3248    }
3249
3250    #[rstest]
3251    fn test_stream_deltas_limit_stops_at_clear() {
3252        // Test that limit=1 with snapshot data returns only the CLEAR delta
3253        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3254binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3255binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0";
3256
3257        let temp_file = std::env::temp_dir().join("test_stream_limit_stops_at_clear.csv");
3258        std::fs::write(&temp_file, csv_data).unwrap();
3259
3260        // limit=1 should only get the CLEAR delta
3261        let stream = stream_deltas(&temp_file, 100, Some(1), Some(1), None, Some(1)).unwrap();
3262        let all_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
3263
3264        assert_eq!(all_deltas.len(), 1);
3265        assert_eq!(all_deltas[0].action, BookAction::Clear);
3266
3267        std::fs::remove_file(&temp_file).ok();
3268    }
3269
3270    #[rstest]
3271    fn test_stream_deltas_limit_includes_clear() {
3272        // Test that limit counts total emitted deltas (including CLEARs)
3273        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3274binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3275binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
3276binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
3277binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
3278binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49998.0,0.5";
3279
3280        let temp_file = std::env::temp_dir().join("test_stream_limit_includes_clear.csv");
3281        std::fs::write(&temp_file, csv_data).unwrap();
3282
3283        // limit=4 should get exactly 4 deltas: 1 CLEAR + 3 data deltas
3284        let stream = stream_deltas(&temp_file, 100, Some(1), Some(1), None, Some(4)).unwrap();
3285        let all_deltas: Vec<_> = stream.flat_map(|chunk| chunk.unwrap()).collect();
3286
3287        assert_eq!(all_deltas.len(), 4);
3288        assert_eq!(all_deltas[0].action, BookAction::Clear);
3289        assert_eq!(all_deltas[1].action, BookAction::Add);
3290        assert_eq!(all_deltas[2].action, BookAction::Add);
3291        assert_eq!(all_deltas[3].action, BookAction::Update);
3292
3293        std::fs::remove_file(&temp_file).ok();
3294    }
3295
3296    #[rstest]
3297    fn test_stream_deltas_limit_sets_f_last() {
3298        // Test that F_LAST is set on the final delta when limit is reached
3299        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3300binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3301binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
3302binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
3303binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
3304binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49998.0,0.5";
3305
3306        let temp_file = std::env::temp_dir().join("test_stream_limit_f_last.csv");
3307        std::fs::write(&temp_file, csv_data).unwrap();
3308
3309        // limit=3 should get 3 deltas with F_LAST on the last one
3310        let stream = stream_deltas(&temp_file, 100, Some(1), Some(1), None, Some(3)).unwrap();
3311        let chunks: Vec<_> = stream.collect();
3312
3313        // Should have 1 chunk with 3 deltas
3314        assert_eq!(chunks.len(), 1);
3315        let deltas = chunks[0].as_ref().unwrap();
3316        assert_eq!(deltas.len(), 3);
3317
3318        // Final delta should have F_LAST flag
3319        assert_eq!(
3320            deltas[2].flags & RecordFlag::F_LAST as u8,
3321            RecordFlag::F_LAST as u8,
3322            "Final delta should have F_LAST flag when limit is reached"
3323        );
3324
3325        std::fs::remove_file(&temp_file).ok();
3326    }
3327
3328    #[rstest]
3329    fn test_stream_deltas_chunk_boundary_no_f_last() {
3330        // Test that F_LAST is NOT set when only chunk_size boundary is hit (more data follows).
3331        // First row is a snapshot (same local_timestamp as the group) so it alone fills the
3332        // first chunk (CLEAR + Add) via the same same-timestamp lookahead being tested.
3333        let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
3334binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
3335binance-futures,BTCUSDT,1640995200000000,1640995200100000,false,ask,50001.0,2.0
3336binance-futures,BTCUSDT,1640995200000000,1640995200100000,false,bid,49999.0,0.5";
3337
3338        let temp_file = std::env::temp_dir().join("test_stream_chunk_no_f_last.csv");
3339        std::fs::write(&temp_file, csv_data).unwrap();
3340
3341        // chunk_size=2, no limit - first chunk should NOT have F_LAST (more data follows)
3342        let mut stream = stream_deltas(&temp_file, 2, Some(1), Some(1), None, None).unwrap();
3343
3344        let chunk1 = stream.next().unwrap().unwrap();
3345        assert_eq!(chunk1.len(), 2);
3346        assert_eq!(chunk1[0].action, BookAction::Clear);
3347
3348        // First chunk's last delta should NOT have F_LAST (more data follows with same timestamp)
3349        assert_eq!(
3350            chunk1[1].flags & RecordFlag::F_LAST as u8,
3351            0,
3352            "Mid-stream chunk should not have F_LAST flag"
3353        );
3354
3355        // Second chunk exists and has F_LAST (end of file)
3356        let chunk2 = stream.next().unwrap().unwrap();
3357        assert_eq!(chunk2.len(), 2);
3358        assert_eq!(
3359            chunk2[1].flags & RecordFlag::F_LAST as u8,
3360            RecordFlag::F_LAST as u8,
3361            "Final chunk at EOF should have F_LAST flag"
3362        );
3363
3364        assert!(stream.next().is_none());
3365
3366        std::fs::remove_file(&temp_file).ok();
3367    }
3368}