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