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