Skip to main content

nautilus_tardis/csv/
stream.rs

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