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