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