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