1mod book;
27mod ohlcv;
28mod rows;
29mod ticks;
30
31use std::path::Path;
32
33use csv::StringRecord;
34use jiff::civil::Date;
35use nautilus_core::correctness::check_in_range_inclusive_usize;
36use nautilus_model::{
37 data::{OrderBookDelta, QuoteTick, TradeTick},
38 identifiers::InstrumentId,
39};
40pub use ohlcv::CoinApiOhlcvLoader;
41
42use self::{
43 book::BookDecoder,
44 rows::{Precision, Rows, partition_date},
45 ticks::{QuoteDecoder, TradeDecoder},
46};
47
48pub const STREAM_CHUNK_SIZE_MAX: usize = 1_000_000;
50
51const LOAD_CHUNK_SIZE: usize = 1_000_000;
52
53const PRECISION_SAMPLE_ROWS: usize = 10_000;
55
56pub fn load_trades(
64 path: impl AsRef<Path>,
65 instrument_id: InstrumentId,
66 price_precision: Option<u8>,
67 size_precision: Option<u8>,
68 limit: Option<usize>,
69) -> anyhow::Result<Vec<TradeTick>> {
70 load(
71 path.as_ref(),
72 |rows| TradeDecoder::new(rows, instrument_id),
73 price_precision,
74 size_precision,
75 limit,
76 )
77}
78
79pub fn load_quotes(
87 path: impl AsRef<Path>,
88 instrument_id: InstrumentId,
89 price_precision: Option<u8>,
90 size_precision: Option<u8>,
91 limit: Option<usize>,
92) -> anyhow::Result<Vec<QuoteTick>> {
93 load(
94 path.as_ref(),
95 |rows| QuoteDecoder::new(rows, instrument_id),
96 price_precision,
97 size_precision,
98 limit,
99 )
100}
101
102pub fn load_deltas(
113 path: impl AsRef<Path>,
114 instrument_id: InstrumentId,
115 date: Option<Date>,
116 price_precision: Option<u8>,
117 size_precision: Option<u8>,
118 limit: Option<usize>,
119) -> anyhow::Result<Vec<OrderBookDelta>> {
120 let path = path.as_ref();
121 let date = book_date(path, date)?;
122 load(
123 path,
124 |rows| BookDecoder::new(rows, instrument_id, date),
125 price_precision,
126 size_precision,
127 limit,
128 )
129}
130
131pub fn stream_trades(
140 path: impl AsRef<Path>,
141 instrument_id: InstrumentId,
142 chunk_size: usize,
143 price_precision: Option<u8>,
144 size_precision: Option<u8>,
145 limit: Option<usize>,
146) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<TradeTick>>>> {
147 stream(
148 path.as_ref(),
149 |rows| TradeDecoder::new(rows, instrument_id),
150 chunk_size,
151 price_precision,
152 size_precision,
153 limit,
154 )
155}
156
157pub fn stream_quotes(
166 path: impl AsRef<Path>,
167 instrument_id: InstrumentId,
168 chunk_size: usize,
169 price_precision: Option<u8>,
170 size_precision: Option<u8>,
171 limit: Option<usize>,
172) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<QuoteTick>>>> {
173 stream(
174 path.as_ref(),
175 |rows| QuoteDecoder::new(rows, instrument_id),
176 chunk_size,
177 price_precision,
178 size_precision,
179 limit,
180 )
181}
182
183pub fn stream_deltas(
196 path: impl AsRef<Path>,
197 instrument_id: InstrumentId,
198 date: Option<Date>,
199 chunk_size: usize,
200 price_precision: Option<u8>,
201 size_precision: Option<u8>,
202 limit: Option<usize>,
203) -> anyhow::Result<impl Iterator<Item = anyhow::Result<Vec<OrderBookDelta>>>> {
204 let path = path.as_ref();
205 let date = book_date(path, date)?;
206 stream(
207 path,
208 |rows| BookDecoder::new(rows, instrument_id, date),
209 chunk_size,
210 price_precision,
211 size_precision,
212 limit,
213 )
214}
215
216fn book_date(path: &Path, date: Option<Date>) -> anyhow::Result<Date> {
217 date.or_else(|| partition_date(path)).ok_or_else(|| {
218 anyhow::anyhow!(
219 "no date given and '{}' is not in a D-YYYYMMDD partition",
220 path.display()
221 )
222 })
223}
224
225pub(crate) trait Decode {
227 type Output;
229
230 fn fit(
236 &self,
237 record: &StringRecord,
238 price: &mut Precision,
239 size: &mut Precision,
240 ) -> anyhow::Result<()>;
241
242 fn decode(
248 &mut self,
249 record: &StringRecord,
250 price: &mut Precision,
251 size: &mut Precision,
252 out: &mut Vec<Self::Output>,
253 ) -> anyhow::Result<()>;
254
255 fn pending(&self) -> usize {
257 0
258 }
259
260 fn finish(&mut self, _rows: &mut Rows, _out: &mut Vec<Self::Output>) -> anyhow::Result<()> {
267 Ok(())
268 }
269
270 fn truncate(out: &mut Vec<Self::Output>, len: usize) {
272 out.truncate(len);
273 }
274
275 fn set_precision(value: &mut Self::Output, price: Option<u8>, size: Option<u8>);
277}
278
279pub(crate) struct FlatFileStream<D: Decode> {
281 rows: Rows,
282 decoder: D,
283 price: Precision,
284 size: Precision,
285 chunk_size: usize,
286 limit: Option<usize>,
287 emitted: usize,
288 is_done: bool,
289}
290
291impl<D: Decode> FlatFileStream<D> {
292 fn read_chunk(&mut self) -> anyhow::Result<Vec<D::Output>> {
293 let mut out = Vec::new();
294 let limit = self.limit.unwrap_or(usize::MAX);
295 while out.len() < self.chunk_size {
296 if self.emitted + out.len() + self.decoder.pending() >= limit {
297 self.decoder.finish(&mut self.rows, &mut out)?;
298 self.is_done = true;
299 break;
300 }
301
302 let Some(record) = self.rows.next()? else {
303 self.decoder.finish(&mut self.rows, &mut out)?;
304 self.is_done = true;
305 break;
306 };
307
308 self.decoder
309 .decode(record, &mut self.price, &mut self.size, &mut out)?;
310 }
311
312 if let Some(remaining) = limit.checked_sub(self.emitted)
313 && out.len() > remaining
314 {
315 D::truncate(&mut out, remaining);
316 self.is_done = true;
317 }
318
319 self.emitted += out.len();
320 Ok(out)
321 }
322}
323
324impl<D: Decode> Iterator for FlatFileStream<D> {
325 type Item = anyhow::Result<Vec<D::Output>>;
326
327 fn next(&mut self) -> Option<Self::Item> {
328 if self.is_done {
329 return None;
330 }
331
332 match self.read_chunk() {
333 Ok(chunk) if chunk.is_empty() => None,
334 Ok(chunk) => Some(Ok(chunk)),
335 Err(e) => {
336 self.is_done = true;
337 Some(Err(e))
338 }
339 }
340 }
341}
342
343fn load<D: Decode>(
344 path: &Path,
345 decoder: impl FnOnce(&mut Rows) -> anyhow::Result<D>,
346 price_precision: Option<u8>,
347 size_precision: Option<u8>,
348 limit: Option<usize>,
349) -> anyhow::Result<Vec<D::Output>> {
350 let mut rows = Rows::open(path)?;
351 let decoder = decoder(&mut rows)?;
352
353 let mut stream = FlatFileStream {
354 rows,
355 decoder,
356 price: Precision::new(price_precision),
357 size: Precision::new(size_precision),
358 chunk_size: LOAD_CHUNK_SIZE,
359 limit,
360 emitted: 0,
361 is_done: false,
362 };
363
364 let mut values = Vec::new();
365 for chunk in stream.by_ref() {
366 values.extend(chunk?);
367 }
368
369 let price = stream.price.is_inferred.then_some(stream.price.value);
371 let size = stream.size.is_inferred.then_some(stream.size.value);
372
373 if price.is_some() || size.is_some() {
374 for value in &mut values {
375 D::set_precision(value, price, size);
376 }
377 }
378
379 Ok(values)
380}
381
382fn stream<D: Decode>(
383 path: &Path,
384 mut decoder: impl FnMut(&mut Rows) -> anyhow::Result<D>,
385 chunk_size: usize,
386 price_precision: Option<u8>,
387 size_precision: Option<u8>,
388 limit: Option<usize>,
389) -> anyhow::Result<FlatFileStream<D>> {
390 check_in_range_inclusive_usize(chunk_size, 1, STREAM_CHUNK_SIZE_MAX, "chunk_size")?;
391
392 let mut price = Precision::new(price_precision);
393 let mut size = Precision::new(size_precision);
394
395 if price.is_inferred || size.is_inferred {
396 let mut rows = Rows::open(path)?;
397 let sampler = decoder(&mut rows)?;
398 for _ in 0..PRECISION_SAMPLE_ROWS {
399 let Some(record) = rows.next()? else {
400 break;
401 };
402
403 sampler.fit(record, &mut price, &mut size)?;
404 }
405 }
406
407 let mut rows = Rows::open(path)?;
408 let decoder = decoder(&mut rows)?;
409
410 Ok(FlatFileStream {
411 rows,
412 decoder,
413 price: price.fixed(),
414 size: size.fixed(),
415 chunk_size,
416 limit,
417 emitted: 0,
418 is_done: false,
419 })
420}
421
422#[cfg(test)]
423mod tests {
424 use std::{
425 fs,
426 io::Write,
427 path::{Path, PathBuf},
428 str::FromStr,
429 };
430
431 use ahash::AHashMap;
432 use flate2::{Compression, write::GzEncoder};
433 use nautilus_core::UnixNanos;
434 use nautilus_model::{
435 data::{Bar, BarType, BookOrder},
436 enums::{AggressorSide, BookAction, OrderSide, RecordFlag},
437 identifiers::TradeId,
438 types::{Price, Quantity, fixed::FIXED_PRECISION},
439 };
440 use rstest::rstest;
441 use rust_decimal::{Decimal, RoundingStrategy};
442 use tempfile::TempDir;
443
444 use super::*;
445 use crate::http::models::Symbol;
446
447 const SNAPSHOT: u8 = RecordFlag::F_SNAPSHOT as u8;
448 const LAST: u8 = RecordFlag::F_LAST as u8;
449 const DAY_START: u64 = 1_756_684_800_000_000_000;
451
452 const BOOK_HEADER: &str =
453 "time_exchange;time_coinapi;update_type;is_buy;entry_px;entry_sx;order_id";
454 const TRADES_HEADER: &str = "time_exchange;time_coinapi;guid;price;base_amount;taker_side";
455 const OHLCV_HEADER: &str = "symbol_id;time_period_start;time_period_end;time_open;time_close;\
456 px_open;px_high;px_low;px_close;sx_cnt;sx_sum";
457
458 fn fixture(name: &str) -> PathBuf {
459 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
460 .join("test_data/csv")
461 .join(name)
462 }
463
464 fn date() -> Date {
465 Date::new(2025, 9, 1).unwrap()
466 }
467
468 fn at(nanos_after_day_start: u64) -> UnixNanos {
469 UnixNanos::from(DAY_START + nanos_after_day_start)
470 }
471
472 fn binance() -> InstrumentId {
473 InstrumentId::from("BTCUSDT.BINANCE")
474 }
475
476 fn coinbase() -> InstrumentId {
477 InstrumentId::from("BTC-USD.COINBASE")
478 }
479
480 fn write_file(dir: &Path, name: &str, lines: &[&str]) -> PathBuf {
481 let path = dir.join(name);
482 fs::create_dir_all(path.parent().unwrap()).unwrap();
483 fs::write(&path, lines.join("\n") + "\n").unwrap();
484 path
485 }
486
487 fn trade_row(guid: &str, price: &str, size: &str) -> String {
488 format!("2025-09-01T00:00:00.0000000;2025-09-01T00:00:00.0000000;{guid};{price};{size};BUY")
489 }
490
491 fn actions(deltas: &[OrderBookDelta]) -> AHashMap<BookAction, usize> {
492 let mut counts = AHashMap::new();
493 for delta in deltas {
494 *counts.entry(delta.action).or_default() += 1;
495 }
496
497 counts
498 }
499
500 #[rstest]
501 fn load_trades_converts_every_field_at_the_inferred_precision() {
502 let trades = load_trades(
503 fixture("trades_binance_spot_btc_usdt_20250901.csv"),
504 binance(),
505 None,
506 None,
507 None,
508 )
509 .unwrap();
510
511 assert_eq!(trades.len(), 50);
512 assert_eq!(
513 trades[0],
514 TradeTick::new(
515 binance(),
516 Price::from("108246.36"),
517 Quantity::from("0.00023"),
518 AggressorSide::Buy,
519 TradeId::from("8a61934b-2ca0-4e94-b4ec-cb5409a28c53"),
520 at(178_000_000),
521 at(299_824_200),
522 )
523 );
524 assert!(trades.iter().all(|t| t.price.precision == 2));
525 assert!(trades.iter().all(|t| t.size.precision == 5));
526 assert!(
527 trades
528 .iter()
529 .any(|t| t.aggressor_side == AggressorSide::Sell)
530 );
531 }
532
533 #[rstest]
534 fn load_trades_uses_explicit_precisions_and_rejects_finer_values() {
535 let path = fixture("trades_coinbase_spot_btc_usd_20250901.csv");
536
537 let trades = load_trades(&path, coinbase(), Some(4), Some(9), Some(3)).unwrap();
538 let error = load_trades(&path, coinbase(), Some(1), None, None).unwrap_err();
539
540 assert_eq!(trades.len(), 3);
541 assert!(trades.iter().all(|t| t.price.precision == 4));
542 assert!(trades.iter().all(|t| t.size.precision == 9));
543 assert_eq!(trades[0].size, Quantity::from("0.04120085"));
544 assert_eq!(
545 error.to_string(),
546 "price 108247.95 is finer than instrument precision 1"
547 );
548 }
549
550 #[rstest]
551 fn load_quotes_converts_every_field_at_the_inferred_precision() {
552 let quotes = load_quotes(
553 fixture("quotes_coinbase_spot_btc_usd_20250901.csv"),
554 coinbase(),
555 None,
556 None,
557 None,
558 )
559 .unwrap();
560
561 assert_eq!(quotes.len(), 50);
562 assert_eq!(
563 quotes[0],
564 QuoteTick::new(
565 coinbase(),
566 Price::from("108247.90"),
567 Price::from("108247.94"),
568 Quantity::from("0.18478034"),
569 Quantity::from("0.19673805"),
570 at(47_937_000),
571 at(453_522_400),
572 )
573 );
574 assert!(
575 quotes
576 .iter()
577 .all(|q| q.bid_price.precision == 2 && q.ask_price.precision == 2)
578 );
579 assert!(
580 quotes
581 .iter()
582 .all(|q| q.bid_size.precision == 8 && q.ask_size.precision == 8)
583 );
584 }
585
586 #[rstest]
587 fn load_deltas_rebuilds_price_levels() {
588 let deltas = load_deltas(
589 fixture("limitbook_full_binance_spot_btc_usdt_20250901.csv"),
590 binance(),
591 Some(date()),
592 None,
593 None,
594 None,
595 )
596 .unwrap();
597
598 let last_indices: Vec<usize> = deltas
599 .iter()
600 .enumerate()
601 .filter(|(_, d)| d.flags & LAST != 0)
602 .map(|(i, _)| i)
603 .collect();
604
605 assert_eq!(deltas.len(), 81);
606 assert_eq!(deltas[0].action, BookAction::Clear);
607 assert_eq!(
608 deltas[1],
609 OrderBookDelta::new(
610 binance(),
611 BookAction::Add,
612 BookOrder::new(
613 OrderSide::Sell,
614 Price::from("108246.36"),
615 Quantity::from("6.61982"),
616 0
617 ),
618 SNAPSHOT,
619 0,
620 at(14_000_000),
621 at(135_378_300),
622 )
623 );
624 assert!(deltas[..=20].iter().all(|d| d.flags & SNAPSHOT != 0));
625 assert!(deltas[21..].iter().all(|d| d.flags & SNAPSHOT == 0));
626 assert_eq!(last_indices, [20, 32, 46, 62, 80]);
627 assert_eq!(actions(&deltas[21..])[&BookAction::Delete], 23);
628 assert_eq!(actions(&deltas[21..])[&BookAction::Update], 37);
629 assert!(
630 deltas[21..]
631 .iter()
632 .filter(|d| d.action == BookAction::Delete)
633 .all(|d| d.order.size.is_zero())
634 );
635 assert!(deltas.iter().all(|d| d.order.price.precision == 2));
636 assert!(deltas.iter().all(|d| d.order.size.precision == 5));
637 }
638
639 #[rstest]
640 fn load_deltas_tracks_resting_orders() {
641 let deltas = load_deltas(
642 fixture("limitbook_full_coinbase_spot_btc_usd_20250901.csv"),
643 coinbase(),
644 Some(date()),
645 None,
646 None,
647 None,
648 )
649 .unwrap();
650
651 let mut book: AHashMap<u64, BookOrder> = AHashMap::new();
652 let mut history: AHashMap<u64, Vec<(BookAction, Quantity)>> = AHashMap::new();
653
654 for delta in &deltas {
655 match delta.action {
656 BookAction::Clear => book.clear(),
657 BookAction::Add | BookAction::Update => {
658 book.insert(delta.order.order_id, delta.order);
659 }
660 BookAction::Delete => {
661 assert_eq!(book.remove(&delta.order.order_id), Some(delta.order));
662 }
663 }
664
665 if delta.action != BookAction::Clear {
666 history
667 .entry(delta.order.order_id)
668 .or_default()
669 .push((delta.action, delta.order.size));
670 }
671 }
672
673 let mut resting: Vec<(OrderSide, Price, Quantity)> = book
674 .values()
675 .map(|o| (o.side.unwrap(), o.price, o.size))
676 .collect();
677 resting.sort_by_key(|(side, price, _)| (*side as u8, *price));
678
679 let counts = actions(&deltas);
680 let q = Quantity::from;
681
682 assert_eq!(deltas.len(), 52);
683 assert_eq!(counts[&BookAction::Add], 26);
684 assert_eq!(counts[&BookAction::Update], 6);
685 assert_eq!(counts[&BookAction::Delete], 19);
686 assert_eq!(deltas.iter().filter(|d| d.flags & LAST != 0).count(), 29);
687 assert_eq!(deltas[13].flags, SNAPSHOT | LAST);
688 assert_eq!(
689 resting,
690 [
691 (OrderSide::Buy, Price::from("108163.01"), q("2.7735913")),
692 (OrderSide::Buy, Price::from("108200"), q("0.00091951")),
693 (OrderSide::Buy, Price::from("108205.44"), q("0.000011")),
694 (OrderSide::Buy, Price::from("108211"), q("0.00047886")),
695 (OrderSide::Sell, Price::from("108275.16"), q("0.000011")),
696 (OrderSide::Sell, Price::from("108275.86"), q("0.00034227")),
697 (OrderSide::Sell, Price::from("108281.57"), q("0.00499456")),
698 ]
699 );
700 assert_eq!(
702 history[&18],
703 [
704 (BookAction::Add, q("0.01439792")),
705 (BookAction::Delete, q("0.01439792"))
706 ]
707 );
708 assert_eq!(
710 history[&20],
711 [
712 (BookAction::Add, q("0.03137")),
713 (BookAction::Update, q("0.03134167")),
714 (BookAction::Update, q("0.00116539")),
715 (BookAction::Delete, q("0.00116539")),
716 ]
717 );
718
719 for order_id in [12, 17, 23, 25] {
721 assert_eq!(history[&order_id].len(), 2);
722 assert_eq!(history[&order_id][1].0, BookAction::Delete);
723 }
724 }
725
726 #[rstest]
727 fn load_deltas_applies_sub_and_skips_changes_to_unknown_orders() {
728 let dir = TempDir::new().unwrap();
729 let path = write_file(
730 dir.path(),
731 "book.csv",
732 &[
733 BOOK_HEADER,
734 "00:00:00.0000000;00:00:00.0000000;SNAPSHOT;1;100;2;a",
735 "00:00:01.0000000;00:00:01.0000000;SUB;1;100;0.5;a",
736 "00:00:02.0000000;00:00:02.0000000;ADD;1;100;3;a",
737 "00:00:03.0000000;00:00:03.0000000;SET;1;99;1;z",
738 "00:00:03.0000000;00:00:03.0000000;DELETE;1;99;1;y",
739 "00:00:04.0000000;00:00:04.0000000;SNAPSHOT;0;101;1;b",
740 "00:00:05.0000000;00:00:05.0000000;DELETE;1;100;3;a",
741 ],
742 );
743
744 let deltas = load_deltas(&path, binance(), Some(date()), None, None, None).unwrap();
745
746 let summary: Vec<(BookAction, u64, Quantity, u8)> = deltas
747 .iter()
748 .map(|d| (d.action, d.order.order_id, d.order.size, d.flags))
749 .collect();
750 let q = Quantity::from;
751 let zero = Quantity::from("0");
752 assert_eq!(
753 summary,
754 [
755 (BookAction::Clear, 0, zero, SNAPSHOT),
756 (BookAction::Add, 1, q("2.0"), SNAPSHOT | LAST),
757 (BookAction::Update, 1, q("1.5"), LAST),
758 (BookAction::Update, 1, q("3.0"), LAST),
760 (BookAction::Clear, 0, zero, SNAPSHOT),
762 (BookAction::Add, 2, q("1.0"), SNAPSHOT | LAST),
763 ]
764 );
765 }
766
767 #[rstest]
768 #[case::negative_fill(
769 "MATCH;1;100;-0.5",
770 None,
771 "Decimal value '-0.5' is negative, Quantity must be non-negative"
772 )]
773 #[case::finer_than_precision(
774 "MATCH;1;100;2.001",
775 Some(2),
776 "entry_sx 2.001 is finer than instrument precision 2"
777 )]
778 fn load_deltas_rejects_an_invalid_fill_before_applying_it(
779 #[case] fill: &str,
780 #[case] size_precision: Option<u8>,
781 #[case] expected: &str,
782 ) {
783 let dir = TempDir::new().unwrap();
784 let update = format!("00:00:01.0000000;00:00:01.0000000;{fill};a");
785 let path = write_file(
786 dir.path(),
787 "book.csv",
788 &[
789 BOOK_HEADER,
790 "00:00:00.0000000;00:00:00.0000000;SNAPSHOT;1;100;2;a",
791 &update,
792 ],
793 );
794
795 let error =
796 load_deltas(&path, binance(), Some(date()), None, size_precision, None).unwrap_err();
797
798 assert_eq!(error.to_string(), expected);
799 }
800
801 #[rstest]
802 fn load_deltas_dates_rows_from_the_partition_when_not_given() {
803 let dir = TempDir::new().unwrap();
804 let source = fixture("limitbook_full_binance_spot_btc_usdt_20250901.csv");
805 let path = dir
806 .path()
807 .join("T-LIMITBOOK_FULL/D-20250901/E-BINANCE/book.csv");
808 fs::create_dir_all(path.parent().unwrap()).unwrap();
809 fs::copy(&source, &path).unwrap();
810
811 let from_partition = load_deltas(&path, binance(), None, None, None, None).unwrap();
812 let explicit = load_deltas(&source, binance(), Some(date()), None, None, None).unwrap();
813 let error = load_deltas(&source, binance(), None, None, None, None).unwrap_err();
814
815 assert_eq!(from_partition, explicit);
816 assert!(
817 error.to_string().starts_with("no date given and '"),
818 "{error}"
819 );
820 }
821
822 #[rstest]
823 fn load_deltas_moves_a_prior_day_close_snapshot_back_one_day() {
824 let dir = TempDir::new().unwrap();
825 let path = write_file(
826 dir.path(),
827 "book.csv",
828 &[
829 BOOK_HEADER,
830 "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;1;100.5;2;a",
831 "00:00:00.5000000;00:00:00.6000000;ADD;0;101;1;b",
832 ],
833 );
834
835 let deltas = load_deltas(&path, binance(), Some(date()), None, None, None).unwrap();
836
837 let previous_close = UnixNanos::from(DAY_START - 1_000_000);
838 assert_eq!(deltas.len(), 3);
839 assert_eq!(
840 deltas
841 .iter()
842 .map(|d| (d.ts_event, d.ts_init, d.flags))
843 .collect::<Vec<_>>(),
844 [
845 (previous_close, previous_close, SNAPSHOT),
846 (previous_close, previous_close, SNAPSHOT | LAST),
847 (at(500_000_000), at(600_000_000), LAST),
848 ]
849 );
850 }
851
852 #[rstest]
853 fn load_deltas_moves_a_receive_time_after_midnight_to_the_next_day() {
854 let dir = TempDir::new().unwrap();
855 let path = write_file(
856 dir.path(),
857 "book.csv",
858 &[
859 BOOK_HEADER,
860 "23:59:59.9000000;00:00:00.1000000;SET;1;100;1;",
861 ],
862 );
863
864 let deltas = load_deltas(&path, binance(), Some(date()), None, None, None).unwrap();
865
866 assert_eq!(deltas.len(), 1);
867 assert_eq!(deltas[0].ts_event, at(86_399_900_000_000));
868 assert_eq!(deltas[0].ts_init, at(86_400_100_000_000));
869 }
870
871 #[rstest]
872 fn load_deltas_stops_reading_once_held_deltas_reach_the_limit() {
873 let dir = TempDir::new().unwrap();
874 let path = write_file(
875 dir.path(),
876 "book.csv",
877 &[
878 BOOK_HEADER,
879 "00:00:00.0000000;00:00:00.0000000;SNAPSHOT;1;100;2;a",
880 "00:00:01.0000000;00:00:01.0000000;SET;1;100.001;1;a",
881 ],
882 );
883
884 let deltas = load_deltas(&path, binance(), Some(date()), Some(2), None, Some(2)).unwrap();
885
886 assert_eq!(
887 deltas
888 .iter()
889 .map(|d| (d.action, d.flags))
890 .collect::<Vec<_>>(),
891 [
892 (BookAction::Clear, SNAPSHOT),
893 (BookAction::Add, SNAPSHOT | LAST)
894 ]
895 );
896 }
897
898 #[rstest]
899 #[case::after_snapshot(2)]
900 #[case::inside_snapshot(2 + 1)]
901 fn load_deltas_dates_a_limited_prior_day_snapshot(#[case] limit: usize) {
902 let dir = TempDir::new().unwrap();
903 let path = write_file(
904 dir.path(),
905 "book.csv",
906 &[
907 BOOK_HEADER,
908 "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;1;100.5;2;a",
909 "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;1;100.4;1;b",
910 "23:59:59.9990000;23:59:59.9990000;SNAPSHOT;0;100.6;1;c",
911 "00:00:00.5000000;00:00:00.6000000;ADD;0;101;1;d",
912 ],
913 );
914
915 let deltas = load_deltas(&path, binance(), Some(date()), None, None, Some(limit)).unwrap();
916
917 let previous_close = UnixNanos::from(DAY_START - 1_000_000);
918 assert_eq!(deltas.len(), limit);
919 assert!(
920 deltas
921 .iter()
922 .all(|d| d.ts_event == previous_close && d.ts_init == previous_close)
923 );
924 assert_eq!(deltas.last().unwrap().flags, SNAPSHOT | LAST);
925 }
926
927 #[rstest]
928 fn load_deltas_ends_a_limited_load_with_a_last_delta() {
929 let deltas = load_deltas(
930 fixture("limitbook_full_binance_spot_btc_usdt_20250901.csv"),
931 binance(),
932 Some(date()),
933 None,
934 None,
935 Some(25),
936 )
937 .unwrap();
938
939 assert_eq!(deltas.len(), 25);
940 assert_eq!(deltas[20].flags, SNAPSHOT | LAST);
941 assert_eq!(deltas[24].flags, LAST);
942 assert!(deltas[21..24].iter().all(|d| d.flags == 0));
943 }
944
945 #[rstest]
946 fn load_reads_gzipped_files() {
947 let dir = TempDir::new().unwrap();
948 let source = fixture("trades_binance_spot_btc_usdt_20250901.csv");
949 let path = dir.path().join("trades.csv.gz");
950 let mut encoder = GzEncoder::new(fs::File::create(&path).unwrap(), Compression::default());
951 encoder.write_all(&fs::read(&source).unwrap()).unwrap();
952 encoder.finish().unwrap();
953
954 let gzipped = load_trades(&path, binance(), None, None, None).unwrap();
955 let plain = load_trades(&source, binance(), None, None, None).unwrap();
956
957 assert_eq!(gzipped, plain);
958 }
959
960 #[rstest]
961 #[case(1)]
962 #[case(7)]
963 #[case(50)]
964 fn stream_trades_yields_the_loaded_trades_in_chunks(#[case] chunk_size: usize) {
965 let path = fixture("trades_binance_spot_btc_usdt_20250901.csv");
966
967 let chunks: Vec<Vec<TradeTick>> =
968 stream_trades(&path, binance(), chunk_size, None, None, None)
969 .unwrap()
970 .collect::<anyhow::Result<_>>()
971 .unwrap();
972 let loaded = load_trades(&path, binance(), None, None, None).unwrap();
973
974 assert!(chunks.iter().all(|chunk| chunk.len() <= chunk_size));
975 assert_eq!(chunks.len(), 50_usize.div_ceil(chunk_size));
976 assert_eq!(chunks.concat(), loaded);
977 assert!(chunks.concat().iter().all(|t| t.size.precision == 5));
978 }
979
980 #[rstest]
981 fn stream_quotes_applies_limit() {
982 let path = fixture("quotes_binance_spot_btc_usdt_20250901.csv");
983
984 let chunks: Vec<Vec<QuoteTick>> = stream_quotes(&path, binance(), 4, None, None, Some(10))
985 .unwrap()
986 .collect::<anyhow::Result<_>>()
987 .unwrap();
988
989 assert_eq!(chunks.iter().map(Vec::len).collect::<Vec<_>>(), [4, 4, 2]);
990 assert_eq!(
991 chunks.concat(),
992 load_quotes(&path, binance(), None, None, Some(10)).unwrap()
993 );
994 }
995
996 #[rstest]
997 fn stream_deltas_ends_every_chunk_at_an_event_boundary() {
998 let path = fixture("limitbook_full_coinbase_spot_btc_usd_20250901.csv");
999
1000 let chunks: Vec<Vec<OrderBookDelta>> =
1001 stream_deltas(&path, coinbase(), Some(date()), 5, None, None, None)
1002 .unwrap()
1003 .collect::<anyhow::Result<_>>()
1004 .unwrap();
1005 let loaded = load_deltas(&path, coinbase(), Some(date()), None, None, None).unwrap();
1006
1007 assert!(
1008 chunks
1009 .iter()
1010 .all(|chunk| chunk.last().unwrap().flags & LAST != 0)
1011 );
1012 assert_eq!(chunks[0].len(), 14);
1013 assert_eq!(chunks.concat(), loaded);
1014 }
1015
1016 #[rstest]
1017 fn load_backfills_the_final_precision_onto_earlier_values() {
1018 let dir = TempDir::new().unwrap();
1019 let first = trade_row("t1", "100.5", "1");
1020 let second = trade_row("t2", "100.25", "0.125");
1021 let path = write_file(dir.path(), "trades.csv", &[TRADES_HEADER, &first, &second]);
1022
1023 let trades = load_trades(&path, binance(), None, None, None).unwrap();
1024
1025 assert_eq!(
1026 trades
1027 .iter()
1028 .map(|t| (t.price, t.price.precision, t.size, t.size.precision))
1029 .collect::<Vec<_>>(),
1030 [
1031 (Price::from("100.50"), 2, Quantity::from("1.000"), 3),
1032 (Price::from("100.25"), 2, Quantity::from("0.125"), 3),
1033 ]
1034 );
1035 }
1036
1037 #[rstest]
1038 fn stream_rejects_a_finer_value_after_its_precision_sample() {
1039 let dir = TempDir::new().unwrap();
1040 let mut lines = vec![TRADES_HEADER.to_string()];
1041 lines.extend((0..PRECISION_SAMPLE_ROWS).map(|i| trade_row(&format!("t{i}"), "100.5", "1")));
1042 lines.push(trade_row("late", "100.25", "1"));
1043 let lines: Vec<&str> = lines.iter().map(String::as_str).collect();
1044 let path = write_file(dir.path(), "trades.csv", &lines);
1045
1046 let results: Vec<anyhow::Result<Vec<TradeTick>>> =
1047 stream_trades(&path, binance(), PRECISION_SAMPLE_ROWS, None, None, None)
1048 .unwrap()
1049 .collect();
1050
1051 assert_eq!(results.len(), 2);
1052 assert_eq!(results[0].as_ref().unwrap().len(), PRECISION_SAMPLE_ROWS);
1053 assert_eq!(
1054 results[1].as_ref().unwrap_err().to_string(),
1055 "price 100.25 is finer than instrument precision 1"
1056 );
1057 assert_eq!(
1058 load_trades(&path, binance(), None, None, None)
1059 .unwrap()
1060 .last()
1061 .unwrap()
1062 .price,
1063 Price::from("100.25")
1064 );
1065 }
1066
1067 #[rstest]
1068 fn stream_yields_an_error_for_a_value_finer_than_its_precision() {
1069 let path = fixture("trades_binance_spot_btc_usdt_20250901.csv");
1070
1071 let mut chunks = stream_trades(&path, binance(), 10, Some(1), None, None).unwrap();
1072
1073 assert_eq!(
1074 chunks.next().unwrap().unwrap_err().to_string(),
1075 "price 108246.36 is finer than instrument precision 1"
1076 );
1077 assert!(chunks.next().is_none());
1078 }
1079
1080 #[rstest]
1081 #[case(0)]
1082 #[case(STREAM_CHUNK_SIZE_MAX + 1)]
1083 fn stream_rejects_chunk_size_out_of_range(#[case] chunk_size: usize) {
1084 let path = fixture("trades_binance_spot_btc_usdt_20250901.csv");
1085
1086 assert!(stream_trades(&path, binance(), chunk_size, None, None, None).is_err());
1087 }
1088
1089 #[rstest]
1090 fn ohlcv_loader_loads_mapped_symbols_at_their_inferred_precisions() {
1091 let first = InstrumentId::from("SOLUSD.BITSTAMP");
1092 let second = InstrumentId::from("DOGEUSD.BITSTAMP");
1093 let loader = CoinApiOhlcvLoader::new([(138_492, first), (5_968_363, second)]);
1094
1095 let bars = loader
1096 .load_bars(
1097 fixture("ohlcv_bitstamp_1day_20250911.csv"),
1098 Some("1DAY"),
1099 None,
1100 )
1101 .unwrap();
1102
1103 let period_end = UnixNanos::from(1_757_635_200_000_000_000);
1104 let volume = Decimal::from_str("689.5507347100003")
1105 .unwrap()
1106 .round_dp_with_strategy(
1107 u32::from(FIXED_PRECISION),
1108 RoundingStrategy::MidpointNearestEven,
1109 )
1110 .normalize();
1111 assert_eq!(bars.len(), 2);
1112 assert_eq!(
1113 bars[0],
1114 Bar::new(
1115 BarType::from("SOLUSD.BITSTAMP-1-DAY-LAST-EXTERNAL"),
1116 Price::from("496.11"),
1117 Price::from("515.47"),
1118 Price::from("493.23"),
1119 Price::from("508.00"),
1120 Quantity::from_decimal(volume).unwrap(),
1121 period_end,
1122 period_end,
1123 )
1124 );
1125 assert_eq!(bars[0].close.precision, 2);
1126 assert_eq!(u32::from(bars[0].volume.precision), volume.scale());
1127 assert_eq!(
1128 bars[1].bar_type,
1129 BarType::from("DOGEUSD.BITSTAMP-1-DAY-LAST-EXTERNAL")
1130 );
1131 assert_eq!(bars[1].open.precision, 5);
1132 assert_eq!(bars[1].volume, Quantity::from("1474.43175271"));
1133 }
1134
1135 #[rstest]
1136 #[case::negative(-100, true)]
1137 #[case::negative_subunit(-25, true)]
1138 #[case::positive_subunit(25, false)]
1139 #[case::positive_midpoint(50, false)]
1140 fn ohlcv_loader_rejects_negative_and_zero_rounded_volumes(
1141 #[case] hundredths: i64,
1142 #[case] negative: bool,
1143 ) {
1144 let dir = TempDir::new().unwrap();
1145 let volume = Decimal::new(1, u32::from(FIXED_PRECISION)) * Decimal::new(hundredths, 2);
1146 let row = format!(
1147 "7;2025-09-11T00:00:00;2025-09-11T00:01:00;\
1148 2025-09-11T00:00:01;2025-09-11T00:00:59;1;2;0.5;1.5;3;{volume}"
1149 );
1150 let path = write_file(dir.path(), "TP-1MIN.csv", &[OHLCV_HEADER, &row]);
1151 let loader = CoinApiOhlcvLoader::new([(7, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1152
1153 let error = loader.load_bars(&path, None, None).unwrap_err();
1154
1155 let expected = if negative {
1156 format!("sx_sum must be non-negative: {volume}")
1157 } else {
1158 format!("sx_sum {volume} rounds to zero at precision {FIXED_PRECISION}")
1159 };
1160
1161 assert_eq!(error.to_string(), expected);
1162 }
1163
1164 #[rstest]
1165 #[case::zero(0, 0)]
1166 #[case::rounded_unit(75, 1)]
1167 #[case::exact_unit(100, 1)]
1168 #[case::midpoint_even_down(250, 2)]
1169 #[case::midpoint_even_up(350, 4)]
1170 fn ohlcv_loader_preserves_representable_volumes(#[case] hundredths: i64, #[case] units: i64) {
1171 let dir = TempDir::new().unwrap();
1172 let unit = Decimal::new(1, u32::from(FIXED_PRECISION));
1173 let volume = unit * Decimal::new(hundredths, 2);
1174 let row = format!(
1175 "7;2025-09-11T00:00:00;2025-09-11T00:01:00;\
1176 2025-09-11T00:00:01;2025-09-11T00:00:59;1;2;0.5;1.5;3;{volume}"
1177 );
1178 let path = write_file(dir.path(), "TP-1MIN.csv", &[OHLCV_HEADER, &row]);
1179 let loader = CoinApiOhlcvLoader::new([(7, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1180
1181 let bars = loader.load_bars(&path, None, None).unwrap();
1182
1183 let period_end = UnixNanos::from(1_757_548_860_000_000_000);
1184 assert_eq!(
1185 bars,
1186 [Bar::new(
1187 BarType::from("SOLUSD.BITSTAMP-1-MINUTE-LAST-EXTERNAL"),
1188 Price::from("1.0"),
1189 Price::from("2.0"),
1190 Price::from("0.5"),
1191 Price::from("1.5"),
1192 Quantity::from_decimal(unit * Decimal::from(units)).unwrap(),
1193 period_end,
1194 period_end,
1195 )]
1196 );
1197 }
1198
1199 #[rstest]
1200 fn ohlcv_loader_from_symbols_maps_symbol_id_int_to_native_instruments() {
1201 let mut symbols: Vec<Symbol> =
1202 serde_json::from_str(include_str!("../../test_data/symbols_spot.json")).unwrap();
1203 let kraken = symbols
1204 .iter_mut()
1205 .find(|symbol| symbol.symbol_id == "KRAKEN_SPOT_BTC_USD")
1206 .unwrap();
1207 kraken.symbol_id_int = None;
1208 let loader = CoinApiOhlcvLoader::from_symbols(&symbols);
1209
1210 let dir = TempDir::new().unwrap();
1211
1212 let row = |symbol_id: &str| {
1213 format!(
1214 "{symbol_id};2025-09-11T00:00:00.0000000;2025-09-11T00:01:00.0000000;\
1215 2025-09-11T00:00:01.0000000;2025-09-11T00:00:59.0000000;1;2;0.5;1.5;3;4"
1216 )
1217 };
1218
1219 let rows = [row("138123"), row("27576"), row("599"), row("1")];
1220 let mut lines = vec![
1221 "symbol_id;time_period_start;time_period_end;time_open;time_close;px_open;px_high;\
1222 px_low;px_close;sx_cnt;sx_sum",
1223 ];
1224 lines.extend(rows.iter().map(String::as_str));
1225 let path = write_file(dir.path(), "TP-1MIN.csv", &lines);
1226
1227 let bars = loader.load_bars(&path, None, None).unwrap();
1228
1229 assert_eq!(
1230 bars.iter().map(|bar| bar.bar_type).collect::<Vec<_>>(),
1231 [
1232 BarType::from("BTCUSDT.BINANCE-1-MINUTE-LAST-EXTERNAL"),
1233 BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
1234 ]
1235 );
1236 }
1237
1238 #[rstest]
1239 fn ohlcv_loader_backfills_each_instruments_final_precision() {
1240 let dir = TempDir::new().unwrap();
1241
1242 let row = |open: &str| {
1243 format!(
1244 "7;2025-09-11T00:00:00.0000000;2025-09-11T00:01:00.0000000;\
1245 2025-09-11T00:00:01.0000000;2025-09-11T00:00:59.0000000;{open};2;0.5;1.5;3;4"
1246 )
1247 };
1248
1249 let (first, second) = (row("1"), row("1.125"));
1250 let path = write_file(dir.path(), "TP-1MIN.csv", &[OHLCV_HEADER, &first, &second]);
1251 let loader = CoinApiOhlcvLoader::new([(7, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1252
1253 let bars = loader.load_bars(&path, None, None).unwrap();
1254
1255 assert_eq!(bars.len(), 2);
1256 assert_eq!(bars[0].open, Price::from("1.000"));
1257 assert_eq!(bars[1].open, Price::from("1.125"));
1258 assert!(bars.iter().all(|bar| {
1259 [bar.open, bar.high, bar.low, bar.close]
1260 .iter()
1261 .all(|price| price.precision == 3)
1262 }));
1263 }
1264
1265 #[rstest]
1266 fn ohlcv_loader_reads_the_period_from_the_file_name() {
1267 let dir = TempDir::new().unwrap();
1268 let path = dir.path().join("TP-1DAY.csv");
1269 fs::copy(fixture("ohlcv_bitstamp_1day_20250911.csv"), &path).unwrap();
1270 let loader = CoinApiOhlcvLoader::new([(138_492, InstrumentId::from("SOLUSD.BITSTAMP"))]);
1271
1272 let bars = loader.load_bars(&path, None, None).unwrap();
1273 let error = loader
1274 .load_bars(fixture("ohlcv_bitstamp_1day_20250911.csv"), None, None)
1275 .unwrap_err();
1276
1277 assert_eq!(bars.len(), 1);
1278 assert_eq!(bars[0].bar_type.spec().to_string(), "1-DAY-LAST");
1279 assert!(
1280 error.to_string().starts_with("no period given and '"),
1281 "{error}"
1282 );
1283 }
1284
1285 #[rstest]
1286 #[case(0)]
1287 #[case(1)]
1288 #[case(2)]
1289 #[case(3)]
1290 fn ohlcv_limit_counts_mapped_bars(#[case] limit: usize) {
1291 let dir = TempDir::new().unwrap();
1292 let path = write_file(
1293 dir.path(),
1294 "TP-1MIN.csv",
1295 &[
1296 OHLCV_HEADER,
1297 "99;2025-09-01T00:00:00;2025-09-01T00:01:00;;;;;;;0;0",
1298 "7;2025-09-01T00:00:00;2025-09-01T00:01:00;;;101.25;103.5;99.75;102.5;3;4.125",
1299 "99;2025-09-01T00:01:00;2025-09-01T00:02:00;;;;;;;0;0",
1300 "7;2025-09-01T00:01:00;2025-09-01T00:02:00;;;102.5;104.75;100.25;103.25;5;6.375",
1301 ],
1302 );
1303 let loader = CoinApiOhlcvLoader::new([(7, coinbase())]);
1304 let expected = [
1305 Bar::new(
1306 BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
1307 Price::from("101.25"),
1308 Price::from("103.50"),
1309 Price::from("99.75"),
1310 Price::from("102.50"),
1311 Quantity::from("4.125"),
1312 at(60_000_000_000),
1313 at(60_000_000_000),
1314 ),
1315 Bar::new(
1316 BarType::from("BTC-USD.COINBASE-1-MINUTE-LAST-EXTERNAL"),
1317 Price::from("102.50"),
1318 Price::from("104.75"),
1319 Price::from("100.25"),
1320 Price::from("103.25"),
1321 Quantity::from("6.375"),
1322 at(120_000_000_000),
1323 at(120_000_000_000),
1324 ),
1325 ];
1326
1327 let bars = loader.load_bars(path, None, Some(limit)).unwrap();
1328
1329 assert_eq!(bars, expected[..limit.min(2)]);
1330 assert!(bars.iter().all(|bar| {
1331 [bar.open, bar.high, bar.low, bar.close]
1332 .iter()
1333 .all(|p| p.precision == 2)
1334 && bar.volume.precision == 3
1335 }));
1336 }
1337
1338 #[rstest]
1339 fn load_quotes_backfills_all_four_precisions() {
1340 let dir = TempDir::new().unwrap();
1341 let path = write_file(
1342 dir.path(),
1343 "quotes.csv",
1344 &[
1345 "time_exchange;time_coinapi;ask_px;ask_sx;bid_px;bid_sx",
1346 "2025-09-01T00:00:00.1000000;2025-09-01T00:00:00.2000000;101.25;2.5;100.5;1",
1347 "2025-09-01T00:00:01.3000000;2025-09-01T00:00:01.4000000;101.375;2.0625;100.125;0.125",
1348 ],
1349 );
1350
1351 let quotes = load_quotes(path, coinbase(), None, None, None).unwrap();
1352
1353 assert_eq!(
1354 quotes,
1355 [
1356 QuoteTick::new(
1357 coinbase(),
1358 Price::from("100.500"),
1359 Price::from("101.250"),
1360 Quantity::from("1.0000"),
1361 Quantity::from("2.5000"),
1362 at(100_000_000),
1363 at(200_000_000)
1364 ),
1365 QuoteTick::new(
1366 coinbase(),
1367 Price::from("100.125"),
1368 Price::from("101.375"),
1369 Quantity::from("0.1250"),
1370 Quantity::from("2.0625"),
1371 at(1_300_000_000),
1372 at(1_400_000_000)
1373 ),
1374 ]
1375 );
1376 assert_eq!(
1377 quotes
1378 .iter()
1379 .map(|q| (
1380 q.bid_price.precision,
1381 q.ask_price.precision,
1382 q.bid_size.precision,
1383 q.ask_size.precision
1384 ))
1385 .collect::<Vec<_>>(),
1386 [(3, 3, 4, 4); 2]
1387 );
1388 }
1389
1390 #[rstest]
1391 #[case(0, false)]
1392 #[case(1, false)]
1393 #[case(0, true)]
1394 #[case(1, true)]
1395 fn snapshot_limit_retains_a_complete_clear_event(
1396 #[case] limit: usize,
1397 #[case] streaming: bool,
1398 ) {
1399 let dir = TempDir::new().unwrap();
1400 let path = write_file(
1401 dir.path(),
1402 "book.csv",
1403 &[
1404 BOOK_HEADER,
1405 "00:00:00.0000000;00:00:00.2500000;SNAPSHOT;1;100.50;2.0;a",
1406 "00:00:01.0000000;00:00:01.5000000;SET;1;101.25;3.0;a",
1407 ],
1408 );
1409
1410 let deltas = if streaming {
1411 stream_deltas(
1412 &path,
1413 coinbase(),
1414 Some(date()),
1415 1,
1416 Some(2),
1417 Some(1),
1418 Some(limit),
1419 )
1420 .unwrap()
1421 .collect::<anyhow::Result<Vec<_>>>()
1422 .unwrap()
1423 .into_iter()
1424 .flatten()
1425 .collect::<Vec<_>>()
1426 } else {
1427 load_deltas(
1428 &path,
1429 coinbase(),
1430 Some(date()),
1431 Some(2),
1432 Some(1),
1433 Some(limit),
1434 )
1435 .unwrap()
1436 };
1437
1438 let mut clear = OrderBookDelta::clear(coinbase(), 0, at(0), at(250_000_000));
1439 clear.flags = SNAPSHOT | LAST;
1440
1441 assert_eq!(deltas, [clear][..limit]);
1442 assert!(
1443 deltas
1444 .iter()
1445 .all(|d| d.order.price.precision == 0 && d.order.size.precision == 0)
1446 );
1447 }
1448
1449 #[rstest]
1450 #[case(false)]
1451 #[case(true)]
1452 fn zero_sized_trades_do_not_consume_the_limit(#[case] streaming: bool) {
1453 let dir = TempDir::new().unwrap();
1454 let zero = trade_row("zero", "99.75", "0");
1455 let first = trade_row("first", "101.25", "2.125");
1456 let second = trade_row("second", "102.50", "3.375");
1457 let path = write_file(
1458 dir.path(),
1459 "trades.csv",
1460 &[TRADES_HEADER, &zero, &first, &zero, &second],
1461 );
1462
1463 let trades = if streaming {
1464 stream_trades(&path, coinbase(), 1, Some(2), Some(3), Some(1))
1465 .unwrap()
1466 .collect::<anyhow::Result<Vec<_>>>()
1467 .unwrap()
1468 .into_iter()
1469 .flatten()
1470 .collect::<Vec<_>>()
1471 } else {
1472 load_trades(&path, coinbase(), Some(2), Some(3), Some(1)).unwrap()
1473 };
1474
1475 assert_eq!(
1476 trades,
1477 [TradeTick::new(
1478 coinbase(),
1479 Price::from("101.25"),
1480 Quantity::from("2.125"),
1481 AggressorSide::Buy,
1482 TradeId::from("first"),
1483 at(0),
1484 at(0)
1485 )]
1486 );
1487 assert_eq!(
1488 (trades[0].price.precision, trades[0].size.precision),
1489 (2, 3)
1490 );
1491 }
1492
1493 #[rstest]
1494 #[case::missing_column(
1495 "time_exchange;time_coinapi;guid;base_amount;taker_side",
1496 "2025-09-01T00:00:00;2025-09-01T00:00:01;first;2;BUY",
1497 "flat file has no 'price' column"
1498 )]
1499 #[case::invalid_price(
1500 TRADES_HEADER,
1501 "2025-09-01T00:00:00;2025-09-01T00:00:01;first;bad;2;BUY",
1502 "invalid price 'bad':"
1503 )]
1504 #[case::invalid_exchange_time(
1505 TRADES_HEADER,
1506 "bad;2025-09-01T00:00:01;first;101.25;2;BUY",
1507 "invalid time_exchange 'bad':"
1508 )]
1509 #[case::invalid_receive_time(
1510 TRADES_HEADER,
1511 "2025-09-01T00:00:00;bad;first;101.25;2;BUY",
1512 "invalid time_coinapi 'bad':"
1513 )]
1514 #[case::invalid_size(
1515 TRADES_HEADER,
1516 "2025-09-01T00:00:00;2025-09-01T00:00:01;first;101.25;bad;BUY",
1517 "invalid base_amount 'bad':"
1518 )]
1519 fn malformed_trade_rows_report_the_field_and_source_value(
1520 #[case] header: &str,
1521 #[case] row: &str,
1522 #[case] context: &str,
1523 ) {
1524 let dir = TempDir::new().unwrap();
1525 let path = write_file(dir.path(), "trades.csv", &[header, row]);
1526
1527 let error = load_trades(path, coinbase(), None, None, None)
1528 .unwrap_err()
1529 .to_string();
1530
1531 if context.ends_with(':') {
1532 assert!(error.starts_with(&format!("{context} ")), "{error}");
1533 } else {
1534 assert_eq!(error, context);
1535 }
1536 }
1537}