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