1use std::{error::Error, path::Path};
17
18use ahash::AHashMap;
19use csv::StringRecord;
20use nautilus_core::UnixNanos;
21use nautilus_model::{
22 data::{
23 DEPTH10_LEN, Data, FundingRateUpdate, NULL_ORDER, OrderBookDelta, OrderBookDepth,
24 QuoteTick, TradeTick,
25 },
26 enums::{OrderSide, RecordFlag},
27 identifiers::InstrumentId,
28 types::{Quantity, fixed::FIXED_PRECISION},
29};
30
31use crate::{
32 common::parse::{parse_instrument_id, parse_timestamp},
33 csv::{
34 create_book_order, create_csv_reader, infer_precision, matches_underlying_filter,
35 normalize_underlying_filters, parse_delta_record, parse_derivative_ticker_record,
36 parse_options_chain_record, parse_options_chain_record_as_quote, parse_quote_record,
37 parse_trade_record,
38 record::{
39 TardisBookUpdateRecord, TardisDerivativeTickerRecord, TardisOptionsChainRecord,
40 TardisOrderBookSnapshot5Record, TardisOrderBookSnapshot25Record, TardisQuoteRecord,
41 TardisTradeRecord,
42 },
43 },
44};
45
46#[derive(Debug, Clone, Copy)]
47pub(in crate::csv) struct OptionsChainPrecision {
48 pub(in crate::csv) price: u8,
49 pub(in crate::csv) size: u8,
50}
51
52impl OptionsChainPrecision {
53 pub(in crate::csv) const fn new(
54 price_precision: Option<u8>,
55 size_precision: Option<u8>,
56 ) -> Self {
57 Self {
58 price: match price_precision {
59 Some(precision) => precision,
60 None => 0,
61 },
62 size: match size_precision {
63 Some(precision) => precision,
64 None => 0,
65 },
66 }
67 }
68
69 pub(in crate::csv) fn update(
70 &mut self,
71 record: &TardisOptionsChainRecord,
72 price_precision: Option<u8>,
73 size_precision: Option<u8>,
74 ) {
75 if price_precision.is_none() {
76 for value in [record.last_price, record.bid_price, record.ask_price]
77 .into_iter()
78 .flatten()
79 {
80 update_precision_if_needed(&mut self.price, value, price_precision);
81 }
82 }
83
84 if size_precision.is_none() {
85 for value in [record.bid_amount, record.ask_amount].into_iter().flatten() {
86 update_precision_if_needed(&mut self.size, value, size_precision);
87 }
88 }
89 }
90}
91
92fn update_precision_if_needed(current: &mut u8, value: f64, explicit: Option<u8>) -> bool {
93 if explicit.is_some() {
94 return false;
95 }
96
97 let inferred = infer_precision(value).min(FIXED_PRECISION);
98 if inferred > *current {
99 *current = inferred;
100 true
101 } else {
102 false
103 }
104}
105
106fn update_deltas_precision(
107 deltas: &mut [OrderBookDelta],
108 price_precision: Option<u8>,
109 size_precision: Option<u8>,
110 current_price_precision: u8,
111 current_size_precision: u8,
112) {
113 for delta in deltas {
114 if price_precision.is_none() {
115 delta.order.price.precision = current_price_precision;
116 }
117
118 if size_precision.is_none() {
119 delta.order.size.precision = current_size_precision;
120 }
121 }
122}
123
124fn update_quotes_precision(
125 quotes: &mut [QuoteTick],
126 price_precision: Option<u8>,
127 size_precision: Option<u8>,
128 current_price_precision: u8,
129 current_size_precision: u8,
130) {
131 for quote in quotes {
132 if price_precision.is_none() {
133 quote.bid_price.precision = current_price_precision;
134 quote.ask_price.precision = current_price_precision;
135 }
136
137 if size_precision.is_none() {
138 quote.bid_size.precision = current_size_precision;
139 quote.ask_size.precision = current_size_precision;
140 }
141 }
142}
143
144fn update_trades_precision(
145 trades: &mut [TradeTick],
146 price_precision: Option<u8>,
147 size_precision: Option<u8>,
148 current_price_precision: u8,
149 current_size_precision: u8,
150) {
151 for trade in trades {
152 if price_precision.is_none() {
153 trade.price.precision = current_price_precision;
154 }
155
156 if size_precision.is_none() {
157 trade.size.precision = current_size_precision;
158 }
159 }
160}
161
162pub fn load_deltas<P: AsRef<Path>>(
170 filepath: P,
171 price_precision: Option<u8>,
172 size_precision: Option<u8>,
173 instrument_id: Option<InstrumentId>,
174 limit: Option<usize>,
175) -> Result<Vec<OrderBookDelta>, Box<dyn Error>> {
176 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
178 let mut deltas: Vec<OrderBookDelta> = Vec::with_capacity(estimated_capacity);
179
180 let mut current_price_precision = price_precision.unwrap_or(0);
181 let mut current_size_precision = size_precision.unwrap_or(0);
182 let mut last_ts_init: Option<UnixNanos> = None;
183 let mut last_is_snapshot = false;
184 let mut seen_first_snapshot = false;
185 let mut skipped_before_snapshot: usize = 0;
186
187 let mut reader = create_csv_reader(filepath)?;
188 let mut record = StringRecord::new();
189
190 while reader.read_record(&mut record)? {
191 if let Some(limit) = limit
192 && deltas.len() >= limit
193 {
194 break;
195 }
196
197 let data: TardisBookUpdateRecord = record.deserialize(None)?;
198
199 if !seen_first_snapshot {
202 if !data.is_snapshot {
203 skipped_before_snapshot += 1;
204 continue;
205 }
206
207 if skipped_before_snapshot > 0 {
208 log::warn!(
209 "Skipped {skipped_before_snapshot} pre-snapshot buffered delta record(s) for \
210 {}/{} (received before the first snapshot row, see \
211 https://docs.tardis.dev/faq/order-books)",
212 data.exchange,
213 data.symbol,
214 );
215 }
216 seen_first_snapshot = true;
217 }
218
219 update_precision_if_needed(&mut current_price_precision, data.price, price_precision);
220 update_precision_if_needed(&mut current_size_precision, data.amount, size_precision);
221
222 let ts_event = parse_timestamp(data.timestamp);
223 let ts_init = parse_timestamp(data.local_timestamp);
224
225 let starts_new_snapshot =
229 data.is_snapshot && (!last_is_snapshot || last_ts_init != Some(ts_init));
230
231 if starts_new_snapshot {
232 let clear_instrument_id =
233 instrument_id.unwrap_or_else(|| parse_instrument_id(&data.exchange, data.symbol));
234
235 if last_ts_init != Some(ts_init)
236 && let Some(last_delta) = deltas.last_mut()
237 {
238 last_delta.flags = RecordFlag::F_LAST as u8;
239 }
240 last_ts_init = Some(ts_init);
241
242 let clear_delta = OrderBookDelta::clear(clear_instrument_id, 0, ts_event, ts_init);
243 deltas.push(clear_delta);
244
245 if let Some(limit) = limit
246 && deltas.len() >= limit
247 {
248 break;
249 }
250 }
251 last_is_snapshot = data.is_snapshot;
252
253 let delta = match parse_delta_record(
254 &data,
255 current_price_precision,
256 current_size_precision,
257 instrument_id,
258 ) {
259 Ok(d) => d,
260 Err(e) => {
261 log::warn!("Skipping invalid delta record: {e}");
262 continue;
263 }
264 };
265
266 let ts_init = delta.ts_init;
267 if last_ts_init != Some(ts_init)
268 && let Some(last_delta) = deltas.last_mut()
269 {
270 last_delta.flags = RecordFlag::F_LAST as u8;
271 }
272
273 last_ts_init = Some(ts_init);
274
275 deltas.push(delta);
276 }
277
278 if !seen_first_snapshot && skipped_before_snapshot > 0 {
279 log::warn!(
280 "No snapshot row found in Tardis CSV: all {skipped_before_snapshot} row(s) were \
281 pre-snapshot buffered records and have been skipped, zero deltas will be produced \
282 (see https://docs.tardis.dev/faq/order-books)"
283 );
284 }
285
286 if let Some(last_delta) = deltas.last_mut() {
288 last_delta.flags = RecordFlag::F_LAST as u8;
289 }
290
291 update_deltas_precision(
294 &mut deltas,
295 price_precision,
296 size_precision,
297 current_price_precision,
298 current_size_precision,
299 );
300
301 Ok(deltas)
302}
303
304pub fn load_depth_from_snapshot5<P: AsRef<Path>>(
316 filepath: P,
317 price_precision: Option<u8>,
318 size_precision: Option<u8>,
319 instrument_id: Option<InstrumentId>,
320 limit: Option<usize>,
321) -> Result<Vec<OrderBookDepth>, Box<dyn Error>> {
322 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
324 let mut depths: Vec<OrderBookDepth> = Vec::with_capacity(estimated_capacity);
325
326 let mut current_price_precision = price_precision.unwrap_or(0);
327 let mut current_size_precision = size_precision.unwrap_or(0);
328
329 let mut reader = create_csv_reader(filepath)?;
330 let mut record = StringRecord::new();
331
332 while reader.read_record(&mut record)? {
333 let data: TardisOrderBookSnapshot5Record = record.deserialize(None)?;
334
335 let mut precision_updated = false;
337
338 if price_precision.is_none()
339 && let Some(bid_price) = data.bids_0_price
340 {
341 let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
342 if inferred_price_precision > current_price_precision {
343 current_price_precision = inferred_price_precision;
344 precision_updated = true;
345 }
346 }
347
348 if size_precision.is_none()
349 && let Some(bid_amount) = data.bids_0_amount
350 {
351 let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
352 if inferred_size_precision > current_size_precision {
353 current_size_precision = inferred_size_precision;
354 precision_updated = true;
355 }
356 }
357
358 if precision_updated {
360 for depth in &mut depths {
361 for order in depth.bids.iter_mut().chain(depth.asks.iter_mut()) {
362 if price_precision.is_none() {
363 order.price.precision = current_price_precision;
364 }
365
366 if size_precision.is_none() {
367 order.size.precision = current_size_precision;
368 }
369 }
370 }
371 }
372
373 let instrument_id = match &instrument_id {
374 Some(id) => *id,
375 None => parse_instrument_id(&data.exchange, data.symbol),
376 };
377 let flags = RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8;
379 let sequence = 0; let ts_event = parse_timestamp(data.timestamp);
381 let ts_init = parse_timestamp(data.local_timestamp);
382
383 let mut bids = [NULL_ORDER; DEPTH10_LEN];
385 let mut asks = [NULL_ORDER; DEPTH10_LEN];
386 let mut bid_counts = [0u32; DEPTH10_LEN];
387 let mut ask_counts = [0u32; DEPTH10_LEN];
388
389 for i in 0..=4 {
390 let (bid_order, bid_count) = create_book_order(
392 OrderSide::Buy,
393 match i {
394 0 => data.bids_0_price,
395 1 => data.bids_1_price,
396 2 => data.bids_2_price,
397 3 => data.bids_3_price,
398 4 => data.bids_4_price,
399 _ => unreachable!("i is constrained to 0..=4 by loop"),
400 },
401 match i {
402 0 => data.bids_0_amount,
403 1 => data.bids_1_amount,
404 2 => data.bids_2_amount,
405 3 => data.bids_3_amount,
406 4 => data.bids_4_amount,
407 _ => unreachable!("i is constrained to 0..=4 by loop"),
408 },
409 current_price_precision,
410 current_size_precision,
411 );
412 bids[i] = bid_order;
413 bid_counts[i] = bid_count;
414
415 let (ask_order, ask_count) = create_book_order(
417 OrderSide::Sell,
418 match i {
419 0 => data.asks_0_price,
420 1 => data.asks_1_price,
421 2 => data.asks_2_price,
422 3 => data.asks_3_price,
423 4 => data.asks_4_price,
424 _ => None, },
426 match i {
427 0 => data.asks_0_amount,
428 1 => data.asks_1_amount,
429 2 => data.asks_2_amount,
430 3 => data.asks_3_amount,
431 4 => data.asks_4_amount,
432 _ => None, },
434 current_price_precision,
435 current_size_precision,
436 );
437 asks[i] = ask_order;
438 ask_counts[i] = ask_count;
439 }
440
441 let depth = OrderBookDepth::new(
442 instrument_id,
443 bids,
444 asks,
445 bid_counts,
446 ask_counts,
447 flags,
448 sequence,
449 ts_event,
450 ts_init,
451 );
452
453 depths.push(depth);
454
455 if let Some(limit) = limit
456 && depths.len() >= limit
457 {
458 break;
459 }
460 }
461
462 Ok(depths)
463}
464
465pub fn load_depth_from_snapshot25<P: AsRef<Path>>(
473 filepath: P,
474 price_precision: Option<u8>,
475 size_precision: Option<u8>,
476 instrument_id: Option<InstrumentId>,
477 limit: Option<usize>,
478) -> Result<Vec<OrderBookDepth>, Box<dyn Error>> {
479 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
481 let mut depths: Vec<OrderBookDepth> = Vec::with_capacity(estimated_capacity);
482
483 let mut current_price_precision = price_precision.unwrap_or(0);
484 let mut current_size_precision = size_precision.unwrap_or(0);
485 let mut reader = create_csv_reader(filepath)?;
486 let mut record = StringRecord::new();
487
488 while reader.read_record(&mut record)? {
489 let data: TardisOrderBookSnapshot25Record = record.deserialize(None)?;
490
491 let mut precision_updated = false;
493
494 if price_precision.is_none()
495 && let Some(bid_price) = data.bids_0_price
496 {
497 let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
498 if inferred_price_precision > current_price_precision {
499 current_price_precision = inferred_price_precision;
500 precision_updated = true;
501 }
502 }
503
504 if size_precision.is_none()
505 && let Some(bid_amount) = data.bids_0_amount
506 {
507 let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
508 if inferred_size_precision > current_size_precision {
509 current_size_precision = inferred_size_precision;
510 precision_updated = true;
511 }
512 }
513
514 if precision_updated {
516 for depth in &mut depths {
517 for order in depth.bids.iter_mut().chain(depth.asks.iter_mut()) {
518 if price_precision.is_none() {
519 order.price.precision = current_price_precision;
520 }
521
522 if size_precision.is_none() {
523 order.size.precision = current_size_precision;
524 }
525 }
526 }
527 }
528
529 let instrument_id = match &instrument_id {
530 Some(id) => *id,
531 None => parse_instrument_id(&data.exchange, data.symbol),
532 };
533 let flags = RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8;
535 let sequence = 0; let ts_event = parse_timestamp(data.timestamp);
537 let ts_init = parse_timestamp(data.local_timestamp);
538
539 let mut bids = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
541 let mut asks = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
542 let mut bid_counts = [0u32; TardisOrderBookSnapshot25Record::LEVELS];
543 let mut ask_counts = [0u32; TardisOrderBookSnapshot25Record::LEVELS];
544
545 for i in 0..TardisOrderBookSnapshot25Record::LEVELS {
547 let (bid_price, bid_amount) = data.bid_level(i);
549 let (bid_order, bid_count) = create_book_order(
550 OrderSide::Buy,
551 bid_price,
552 bid_amount,
553 current_price_precision,
554 current_size_precision,
555 );
556 bids[i] = bid_order;
557 bid_counts[i] = bid_count;
558
559 let (ask_price, ask_amount) = data.ask_level(i);
561 let (ask_order, ask_count) = create_book_order(
562 OrderSide::Sell,
563 ask_price,
564 ask_amount,
565 current_price_precision,
566 current_size_precision,
567 );
568 asks[i] = ask_order;
569 ask_counts[i] = ask_count;
570 }
571
572 let depth = OrderBookDepth::new(
573 instrument_id,
574 bids,
575 asks,
576 bid_counts,
577 ask_counts,
578 flags,
579 sequence,
580 ts_event,
581 ts_init,
582 );
583
584 depths.push(depth);
585
586 if let Some(limit) = limit
587 && depths.len() >= limit
588 {
589 break;
590 }
591 }
592
593 Ok(depths)
594}
595
596pub fn load_quotes<P: AsRef<Path>>(
604 filepath: P,
605 price_precision: Option<u8>,
606 size_precision: Option<u8>,
607 instrument_id: Option<InstrumentId>,
608 limit: Option<usize>,
609) -> Result<Vec<QuoteTick>, Box<dyn Error>> {
610 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
612 let mut quotes: Vec<QuoteTick> = Vec::with_capacity(estimated_capacity);
613
614 let mut current_price_precision = price_precision.unwrap_or(0);
615 let mut current_size_precision = size_precision.unwrap_or(0);
616 let mut reader = create_csv_reader(filepath)?;
617 let mut record = StringRecord::new();
618
619 while reader.read_record(&mut record)? {
620 let data: TardisQuoteRecord = record.deserialize(None)?;
621
622 if price_precision.is_none()
623 && let Some(bid_price) = data.bid_price
624 {
625 let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
626 if inferred_price_precision > current_price_precision {
627 current_price_precision = inferred_price_precision;
628 }
629 }
630
631 if size_precision.is_none()
632 && let Some(bid_amount) = data.bid_amount
633 {
634 let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
635 if inferred_size_precision > current_size_precision {
636 current_size_precision = inferred_size_precision;
637 }
638 }
639
640 let quote = parse_quote_record(
641 &data,
642 current_price_precision,
643 current_size_precision,
644 instrument_id,
645 );
646
647 quotes.push(quote);
648
649 if let Some(limit) = limit
650 && quotes.len() >= limit
651 {
652 break;
653 }
654 }
655
656 update_quotes_precision(
659 &mut quotes,
660 price_precision,
661 size_precision,
662 current_price_precision,
663 current_size_precision,
664 );
665
666 Ok(quotes)
667}
668
669pub fn load_trades<P: AsRef<Path>>(
677 filepath: P,
678 price_precision: Option<u8>,
679 size_precision: Option<u8>,
680 instrument_id: Option<InstrumentId>,
681 limit: Option<usize>,
682) -> Result<Vec<TradeTick>, Box<dyn Error>> {
683 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
685 let mut trades: Vec<TradeTick> = Vec::with_capacity(estimated_capacity);
686
687 let mut current_price_precision = price_precision.unwrap_or(0);
688 let mut current_size_precision = size_precision.unwrap_or(0);
689 let mut reader = create_csv_reader(filepath)?;
690 let mut record = StringRecord::new();
691
692 while reader.read_record(&mut record)? {
693 let data: TardisTradeRecord = record.deserialize(None)?;
694
695 if price_precision.is_none() {
696 let inferred_price_precision = infer_precision(data.price).min(FIXED_PRECISION);
697 if inferred_price_precision > current_price_precision {
698 current_price_precision = inferred_price_precision;
699 }
700 }
701
702 if size_precision.is_none() {
703 let inferred_size_precision = infer_precision(data.amount).min(FIXED_PRECISION);
704 if inferred_size_precision > current_size_precision {
705 current_size_precision = inferred_size_precision;
706 }
707 }
708
709 let size = Quantity::new_checked(data.amount, current_size_precision)?;
710
711 if size.is_positive() {
712 let trade = parse_trade_record(&data, size, current_price_precision, instrument_id);
713
714 trades.push(trade);
715
716 if let Some(limit) = limit
717 && trades.len() >= limit
718 {
719 break;
720 }
721 } else {
722 log::warn!("Skipping zero-sized trade: {data:?}");
723 }
724 }
725
726 update_trades_precision(
729 &mut trades,
730 price_precision,
731 size_precision,
732 current_price_precision,
733 current_size_precision,
734 );
735
736 Ok(trades)
737}
738
739pub fn load_funding_rates<P: AsRef<Path>>(
749 filepath: P,
750 instrument_id: Option<InstrumentId>,
751 limit: Option<usize>,
752) -> Result<Vec<FundingRateUpdate>, Box<dyn Error>> {
753 let estimated_capacity = limit.unwrap_or(100_000).min(1_000_000);
755 let mut funding_rates: Vec<FundingRateUpdate> = Vec::with_capacity(estimated_capacity);
756
757 let mut reader = create_csv_reader(filepath)?;
758 let mut record = StringRecord::new();
759
760 while reader.read_record(&mut record)? {
761 let data: TardisDerivativeTickerRecord = record.deserialize(None)?;
762
763 if let Some(funding_rate) = parse_derivative_ticker_record(&data, instrument_id) {
765 funding_rates.push(funding_rate);
766
767 if let Some(limit) = limit
768 && funding_rates.len() >= limit
769 {
770 break;
771 }
772 }
773 }
774
775 Ok(funding_rates)
776}
777
778pub fn load_options_chain<P: AsRef<Path>>(
788 filepath: P,
789 underlyings: Option<Vec<String>>,
790 price_precision: Option<u8>,
791 size_precision: Option<u8>,
792 limit: Option<usize>,
793) -> Result<Vec<Data>, Box<dyn Error>> {
794 let underlyings = normalize_underlying_filters(underlyings);
795 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
796 let mut records: Vec<TardisOptionsChainRecord> = Vec::with_capacity(estimated_capacity);
797 let mut precision_by_instrument: AHashMap<InstrumentId, OptionsChainPrecision> =
798 AHashMap::new();
799
800 let mut reader = create_csv_reader(filepath)?;
801 let mut record = StringRecord::new();
802
803 while reader.read_record(&mut record)? {
804 if let Some(underlyings) = underlyings.as_deref() {
805 let Some(symbol) = record.get(1) else {
806 continue;
807 };
808 let symbol = symbol.to_uppercase();
809 if !matches_underlying_filter(&symbol, Some(underlyings)) {
810 continue;
811 }
812 }
813
814 let data: TardisOptionsChainRecord = record.deserialize(None)?;
815 let instrument_id = parse_instrument_id(&data.exchange, data.symbol);
816 precision_by_instrument
817 .entry(instrument_id)
818 .or_insert_with(|| OptionsChainPrecision::new(price_precision, size_precision))
819 .update(&data, price_precision, size_precision);
820 records.push(data);
821
822 if let Some(limit) = limit
823 && records.len() >= limit
824 {
825 break;
826 }
827 }
828
829 let mut output = Vec::with_capacity(records.len() * 2);
830 for record in records {
831 let instrument_id = parse_instrument_id(&record.exchange, record.symbol);
832 let precision = precision_by_instrument
833 .get(&instrument_id)
834 .copied()
835 .unwrap_or_else(|| OptionsChainPrecision::new(price_precision, size_precision));
836
837 if let Some(quote) = parse_options_chain_record_as_quote(
838 &record,
839 precision.price,
840 precision.size,
841 instrument_id,
842 )? {
843 output.push(Data::Quote(quote));
844 }
845
846 output.push(Data::OptionGreeks(parse_options_chain_record(
847 &record,
848 instrument_id,
849 )));
850 }
851
852 Ok(output)
853}
854
855#[cfg(test)]
856mod tests {
857 use std::fs;
858 #[cfg(feature = "arrow")]
859 use std::{fs::File, sync::Arc};
860
861 #[cfg(feature = "arrow")]
862 use nautilus_core::paths::get_test_data_path as get_test_data_root;
863 use nautilus_model::{
864 enums::{AggressorSide, BookAction, OrderSide},
865 identifiers::{InstrumentId, TradeId},
866 types::Price,
867 };
868 #[cfg(feature = "arrow")]
869 use nautilus_serialization::arrow::{ArrowSchemaProvider, EncodeToRecordBatch};
870 use nautilus_testkit::common::{
871 get_tardis_binance_snapshot5_path, get_tardis_binance_snapshot25_path,
872 get_tardis_bitmex_trades_path, get_tardis_deribit_book_l2_path,
873 get_tardis_huobi_quotes_path,
874 };
875 #[cfg(feature = "arrow")]
876 use parquet::{arrow::ArrowWriter, file::properties::WriterProperties};
877 use rstest::*;
878 use rust_decimal_macros::dec;
879
880 use super::*;
881 use crate::common::{parse::parse_price, testing::get_test_data_path};
882
883 #[rstest]
884 #[case(0.0, 0)]
885 #[case(42.0, 0)]
886 #[case(0.1, 1)]
887 #[case(0.25, 2)]
888 #[case(123.0001, 4)]
889 #[case(-42.987654321, 9)]
890 #[case(1.234_567_890_123, 12)]
891 fn test_infer_precision(#[case] input: f64, #[case] expected: u8) {
892 assert_eq!(infer_precision(input), expected);
893 }
894
895 #[rstest]
896 pub fn test_dynamic_precision_inference() {
897 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
898binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
899binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0
900binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50000.12,1.5
901binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49999.123,3.0
902binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50000.1234,0.5";
903
904 let temp_file = std::env::temp_dir().join("test_dynamic_precision.csv");
905 std::fs::write(&temp_file, csv_data).unwrap();
906
907 let deltas = load_deltas(&temp_file, None, None, None, None).unwrap();
908
909 assert_eq!(deltas.len(), 6);
911
912 for (i, delta) in deltas.iter().skip(1).enumerate() {
914 assert_eq!(
915 delta.order.price.precision, 4,
916 "Price precision should be 4 for delta {i}",
917 );
918 assert_eq!(
919 delta.order.size.precision, 1,
920 "Size precision should be 1 for delta {i}",
921 );
922 }
923
924 assert_eq!(deltas[0].action, BookAction::Clear);
927
928 assert_eq!(deltas[1].order.price, parse_price(50000.0, 4));
929 assert_eq!(deltas[1].order.size, Quantity::new(1.0, 1));
930
931 assert_eq!(deltas[2].order.price, parse_price(49999.5, 4));
932 assert_eq!(deltas[2].order.size, Quantity::new(2.0, 1));
933
934 assert_eq!(deltas[3].order.price, parse_price(50000.12, 4));
935 assert_eq!(deltas[3].order.size, Quantity::new(1.5, 1));
936
937 assert_eq!(deltas[4].order.price, parse_price(49999.123, 4));
938 assert_eq!(deltas[4].order.size, Quantity::new(3.0, 1));
939
940 assert_eq!(deltas[5].order.price, parse_price(50000.1234, 4));
941 assert_eq!(deltas[5].order.size, Quantity::new(0.5, 1));
942
943 assert_eq!(
944 deltas[1].order.price.precision,
945 deltas[5].order.price.precision
946 );
947 assert_eq!(
948 deltas[1].order.size.precision,
949 deltas[3].order.size.precision
950 );
951
952 std::fs::remove_file(&temp_file).ok();
953 }
954
955 #[rstest]
956 #[case(Some(1), Some(0))] #[case(None, None)] pub fn test_read_deltas(
959 #[case] price_precision: Option<u8>,
960 #[case] size_precision: Option<u8>,
961 ) {
962 let filepath = get_tardis_deribit_book_l2_path();
963 let deltas =
964 load_deltas(filepath, price_precision, size_precision, None, Some(100)).unwrap();
965
966 assert_eq!(deltas.len(), 16);
968
969 assert_eq!(deltas[0].action, BookAction::Clear);
971
972 assert_eq!(
974 deltas[1].instrument_id,
975 InstrumentId::from("BTC-PERPETUAL.DERIBIT")
976 );
977 assert_eq!(deltas[1].action, BookAction::Add);
978 assert_eq!(deltas[1].order.side, OrderSide::Sell.into());
979 assert_eq!(deltas[1].order.price, Price::from("6421.5"));
980 assert_eq!(deltas[1].order.size, Quantity::from("18640"));
981 assert_eq!(deltas[1].flags, 0);
982 assert_eq!(deltas[1].sequence, 0);
983 assert_eq!(deltas[1].ts_event, 1585699200245000000);
984 assert_eq!(deltas[1].ts_init, 1585699200355684000);
985 }
986
987 #[rstest]
988 #[case(Some(2), Some(3))] #[case(None, None)] pub fn test_read_depths_from_snapshot5(
991 #[case] price_precision: Option<u8>,
992 #[case] size_precision: Option<u8>,
993 ) {
994 let filepath = get_tardis_binance_snapshot5_path();
995 let depths =
996 load_depth_from_snapshot5(filepath, price_precision, size_precision, None, Some(100))
997 .unwrap();
998
999 assert_eq!(depths.len(), 10);
1000 assert_eq!(
1001 depths[0].instrument_id,
1002 InstrumentId::from("BTCUSDT.BINANCE")
1003 );
1004 assert_eq!(depths[0].bids.len(), 5);
1005 assert_eq!(depths[0].bids[0].price, Price::from("11657.07"));
1006 assert_eq!(depths[0].bids[0].size, Quantity::from("10.896"));
1007 assert_eq!(depths[0].bids[0].side, OrderSide::Buy.into());
1008 assert_eq!(depths[0].bids[0].order_id, 0);
1009 assert_eq!(depths[0].asks.len(), 5);
1010 assert_eq!(depths[0].asks[0].price, Price::from("11657.08"));
1011 assert_eq!(depths[0].asks[0].size, Quantity::from("1.714"));
1012 assert_eq!(depths[0].asks[0].side, OrderSide::Sell.into());
1013 assert_eq!(depths[0].asks[0].order_id, 0);
1014 assert_eq!(depths[0].bid_counts.as_slice(), &[1; 5]);
1015 assert_eq!(depths[0].ask_counts.as_slice(), &[1; 5]);
1016 assert_eq!(
1018 depths[0].flags,
1019 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1020 );
1021 assert_eq!(depths[0].ts_event, 1598918403696000000);
1022 assert_eq!(depths[0].ts_init, 1598918403810979000);
1023 assert_eq!(depths[0].sequence, 0);
1024 }
1025
1026 #[rstest]
1027 #[case(Some(2), Some(3))] #[case(None, None)] pub fn test_read_depths_from_snapshot25(
1030 #[case] price_precision: Option<u8>,
1031 #[case] size_precision: Option<u8>,
1032 ) {
1033 let filepath = get_tardis_binance_snapshot25_path();
1034 let depths =
1035 load_depth_from_snapshot25(filepath, price_precision, size_precision, None, Some(100))
1036 .unwrap();
1037
1038 assert_eq!(depths.len(), 10);
1039 assert_eq!(
1040 depths[0].instrument_id,
1041 InstrumentId::from("BTCUSDT.BINANCE")
1042 );
1043 assert_eq!(depths[0].bids.len(), 25);
1044 assert_eq!(depths[0].bids[0].price, Price::from("11657.07"));
1045 assert_eq!(depths[0].bids[0].size, Quantity::from("10.896"));
1046 assert_eq!(depths[0].bids[0].side, OrderSide::Buy.into());
1047 assert_eq!(depths[0].bids[0].order_id, 0);
1048 assert_eq!(depths[0].asks.len(), 25);
1049 assert_eq!(depths[0].asks[0].price, Price::from("11657.08"));
1050 assert_eq!(depths[0].asks[0].size, Quantity::from("1.714"));
1051 assert_eq!(depths[0].asks[0].side, OrderSide::Sell.into());
1052 assert_eq!(depths[0].asks[0].order_id, 0);
1053 assert_eq!(depths[0].bid_counts[0], 1);
1054 assert_eq!(depths[0].ask_counts[0], 1);
1055 assert_eq!(
1057 depths[0].flags,
1058 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1059 );
1060 assert_eq!(depths[0].ts_event, 1598918403696000000);
1061 assert_eq!(depths[0].ts_init, 1598918403810979000);
1062 assert_eq!(depths[0].sequence, 0);
1063 }
1064
1065 #[rstest]
1066 #[case(Some(1), Some(0))] #[case(None, None)] pub fn test_read_quotes(
1069 #[case] price_precision: Option<u8>,
1070 #[case] size_precision: Option<u8>,
1071 ) {
1072 let filepath = get_tardis_huobi_quotes_path();
1073 let quotes =
1074 load_quotes(filepath, price_precision, size_precision, None, Some(100)).unwrap();
1075
1076 assert_eq!(quotes.len(), 10);
1077 assert_eq!(
1078 quotes[0].instrument_id,
1079 InstrumentId::from("BTC-USD.HUOBI_DELIVERY")
1080 );
1081 assert_eq!(quotes[0].bid_price, Price::from("8629.2"));
1082 assert_eq!(quotes[0].bid_size, Quantity::from("806"));
1083 assert_eq!(quotes[0].ask_price, Price::from("8629.3"));
1084 assert_eq!(quotes[0].ask_size, Quantity::from("5494"));
1085 assert_eq!(quotes[0].ts_event, 1588291201099000000);
1086 assert_eq!(quotes[0].ts_init, 1588291201234268000);
1087 }
1088
1089 #[rstest]
1090 fn test_load_options_chain_filters_underlying_and_emits_quote_then_greeks() {
1091 let filepath = get_test_data_path("options_chain.csv");
1092 let data =
1093 load_options_chain(filepath, Some(vec!["btc-".to_string()]), None, None, None).unwrap();
1094
1095 assert_eq!(data.len(), 9);
1096
1097 let Data::Quote(quote) = &data[0] else {
1098 panic!("Expected first data item to be Quote");
1099 };
1100 let Data::OptionGreeks(greeks) = &data[1] else {
1101 panic!("Expected second data item to be OptionGreeks");
1102 };
1103
1104 assert_eq!(
1105 quote.instrument_id,
1106 InstrumentId::from("BTC-9JUN20-9875-P.DERIBIT")
1107 );
1108 assert_eq!(quote.bid_price, Price::from("0.0205"));
1109 assert_eq!(quote.ask_price, Price::from("0.0235"));
1110 assert_eq!(quote.bid_size, Quantity::from("15.1"));
1111 assert_eq!(quote.ask_size, Quantity::from("15.2"));
1112 assert_eq!(quote.bid_price.precision, 4);
1113 assert_eq!(quote.bid_size.precision, 1);
1114
1115 assert_eq!(greeks.instrument_id, quote.instrument_id);
1116 assert_eq!(greeks.greeks.delta, -0.61752);
1117 assert_eq!(greeks.mark_iv, Some(62.89));
1118 assert_eq!(greeks.underlying_price, Some(9756.36));
1119 }
1120
1121 #[rstest]
1122 fn test_load_options_chain_missing_bbo_emits_greeks_only_with_default_greeks() {
1123 let filepath = get_test_data_path("options_chain.csv");
1124 let data = load_options_chain(
1125 filepath,
1126 Some(vec!["BTC-10JUN20".to_string()]),
1127 None,
1128 None,
1129 None,
1130 )
1131 .unwrap();
1132
1133 assert_eq!(data.len(), 1);
1134
1135 let Data::OptionGreeks(greeks) = &data[0] else {
1136 panic!("Expected OptionGreeks, was {:?}", data[0]);
1137 };
1138
1139 assert_eq!(
1140 greeks.instrument_id,
1141 InstrumentId::from("BTC-10JUN20-10000-C.DERIBIT")
1142 );
1143 assert_eq!(greeks.open_interest, None);
1144 assert_eq!(greeks.bid_iv, None);
1145 assert_eq!(greeks.ask_iv, None);
1146 assert_eq!(greeks.greeks.delta, 0.0);
1147 assert_eq!(greeks.greeks.gamma, 0.0);
1148 assert_eq!(greeks.greeks.vega, 0.0);
1149 assert_eq!(greeks.greeks.theta, 0.0);
1150 assert_eq!(greeks.greeks.rho, 0.0);
1151 }
1152
1153 #[rstest]
1154 fn test_load_options_chain_rejects_zero_bbo_size() {
1155 let temp_file = tempfile::NamedTempFile::new().unwrap();
1156 let csv_data = "exchange,symbol,timestamp,local_timestamp,type,strike_price,expiration,open_interest,last_price,bid_price,bid_amount,bid_iv,ask_price,ask_amount,ask_iv,mark_price,mark_iv,underlying_index,underlying_price,delta,gamma,vega,theta,rho
1157deribit,BTC-9JUN20-9875-P,1591574399413000,1591574400196008,put,9875,1591689600000000,0.1,0.0295,0.0205,0,55.91,0.0235,15.2,68.94,0.02210436,62.89,SYN.BTC-9JUN20,9756.36,-0.61752,0.00103,2.24964,-53.05655,-0.22796";
1158 fs::write(temp_file.path(), csv_data).unwrap();
1159
1160 let error = load_options_chain(temp_file.path(), None, None, None, None).unwrap_err();
1161
1162 assert_eq!(error.to_string(), "value was zero");
1163 }
1164
1165 #[rstest]
1166 fn test_load_options_chain_infers_precision_per_instrument() {
1167 let filepath = get_test_data_path("options_chain.csv");
1168 let data =
1169 load_options_chain(filepath, Some(vec!["ETH-".to_string()]), None, None, None).unwrap();
1170
1171 assert_eq!(data.len(), 3);
1172
1173 let Data::Quote(quote) = &data[0] else {
1174 panic!("Expected first data item to be Quote");
1175 };
1176
1177 assert_eq!(
1178 quote.instrument_id,
1179 InstrumentId::from("ETH-9JUN20-250-P.DERIBIT")
1180 );
1181 assert_eq!(quote.bid_price, Price::from("0.12345"));
1182 assert_eq!(quote.ask_price, Price::from("0.12456"));
1183 assert_eq!(quote.bid_size, Quantity::from("0.123456"));
1184 assert_eq!(quote.ask_size, Quantity::from("0.223456"));
1185 assert_eq!(quote.bid_price.precision, 5);
1186 assert_eq!(quote.bid_size.precision, 6);
1187
1188 assert!(matches!(data[1], Data::OptionGreeks(_)));
1189 assert!(matches!(data[2], Data::OptionGreeks(_)));
1190 }
1191
1192 #[rstest]
1193 #[case(Some(1), Some(0))] #[case(None, None)] pub fn test_read_trades(
1196 #[case] price_precision: Option<u8>,
1197 #[case] size_precision: Option<u8>,
1198 ) {
1199 let filepath = get_tardis_bitmex_trades_path();
1200 let trades =
1201 load_trades(filepath, price_precision, size_precision, None, Some(100)).unwrap();
1202
1203 assert_eq!(trades.len(), 10);
1204 assert_eq!(trades[0].instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1205 assert_eq!(trades[0].price, Price::from("8531.5"));
1206 assert_eq!(trades[0].size, Quantity::from("2152"));
1207 assert_eq!(trades[0].aggressor_side, AggressorSide::Sell);
1208 assert_eq!(
1209 trades[0].trade_id,
1210 TradeId::new("ccc3c1fa-212c-e8b0-1706-9b9c4f3d5ecf")
1211 );
1212 assert_eq!(trades[0].ts_event, 1583020803145000000);
1213 assert_eq!(trades[0].ts_init, 1583020803307160000);
1214 }
1215
1216 #[rstest]
1217 pub fn test_load_trades_derives_id_when_csv_id_empty() {
1218 let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
1221binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50000.0,1.0
1222binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50000.0,1.0
1223binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50001.0,1.0";
1224
1225 let temp_file = std::env::temp_dir().join("test_load_trades_empty_id.csv");
1226 std::fs::write(&temp_file, csv_data).unwrap();
1227
1228 let trades = load_trades(&temp_file, Some(2), Some(1), None, None).unwrap();
1229 assert_eq!(trades.len(), 3);
1230
1231 assert_eq!(trades[0].trade_id, trades[1].trade_id);
1232 assert_eq!(trades[0].trade_id.as_str().len(), 16);
1233 assert_ne!(trades[0].trade_id, trades[2].trade_id);
1234
1235 std::fs::remove_file(&temp_file).ok();
1236 }
1237
1238 #[rstest]
1239 pub fn test_load_trades_with_zero_sized_trade() {
1240 let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
1242binance,BTCUSDT,1640995200000000,1640995200100000,trade1,buy,50000.0,1.0
1243binance,BTCUSDT,1640995201000000,1640995201100000,trade2,sell,49999.5,0.0
1244binance,BTCUSDT,1640995202000000,1640995202100000,trade3,buy,50000.12,1.5
1245binance,BTCUSDT,1640995203000000,1640995203100000,trade4,sell,49999.123,3.0";
1246
1247 let temp_file = std::env::temp_dir().join("test_load_trades_zero_size.csv");
1248 std::fs::write(&temp_file, csv_data).unwrap();
1249
1250 let trades = load_trades(
1251 &temp_file,
1252 Some(4),
1253 Some(1),
1254 None,
1255 None, )
1257 .unwrap();
1258
1259 assert_eq!(trades.len(), 3);
1261
1262 assert_eq!(trades[0].size, Quantity::from("1.0"));
1264 assert_eq!(trades[1].size, Quantity::from("1.5"));
1265 assert_eq!(trades[2].size, Quantity::from("3.0"));
1266
1267 assert_eq!(trades[0].trade_id, TradeId::new("trade1"));
1269 assert_eq!(trades[1].trade_id, TradeId::new("trade3"));
1270 assert_eq!(trades[2].trade_id, TradeId::new("trade4"));
1271
1272 std::fs::remove_file(&temp_file).ok();
1273 }
1274
1275 #[rstest]
1276 pub fn test_load_trades_from_local_file() {
1277 let filepath = get_test_data_path("csv/trades_1.csv");
1278 let trades = load_trades(filepath, Some(1), Some(0), None, None).unwrap();
1279 assert_eq!(trades.len(), 2);
1280 assert_eq!(trades[0].price, Price::from("8531.5"));
1281 assert_eq!(trades[1].size, Quantity::from("1000"));
1282 }
1283
1284 #[rstest]
1285 pub fn test_load_deltas_from_local_file() {
1286 let filepath = get_test_data_path("csv/deltas_1.csv");
1287 let deltas = load_deltas(filepath, Some(1), Some(0), None, None).unwrap();
1288
1289 assert_eq!(deltas.len(), 3);
1291 assert_eq!(deltas[0].action, BookAction::Clear);
1292 assert_eq!(deltas[1].order.price, Price::from("6421.5"));
1293 assert_eq!(deltas[2].order.size, Quantity::from("10000"));
1294 }
1295
1296 #[rstest]
1297 fn test_load_deltas_skips_rows_before_first_snapshot() {
1298 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1301binance-futures,BTCUSDT,1,1,false,bid,99.0,1.0
1302binance-futures,BTCUSDT,2,2,false,ask,101.0,2.0
1303binance-futures,BTCUSDT,3,3,true,bid,100.0,5.0
1304binance-futures,BTCUSDT,3,3,true,ask,100.5,6.0
1305binance-futures,BTCUSDT,4,4,false,bid,100.0,7.0";
1306
1307 let temp_file = std::env::temp_dir().join("test_load_deltas_pre_snapshot_orphans.csv");
1308 std::fs::write(&temp_file, csv_data).unwrap();
1309
1310 let deltas = load_deltas(&temp_file, Some(1), Some(0), None, None).unwrap();
1311
1312 assert_eq!(deltas.len(), 4);
1314 assert_eq!(deltas[0].action, BookAction::Clear);
1315 assert_eq!(deltas[0].ts_event, UnixNanos::from(3_000));
1316 assert_eq!(deltas[0].ts_init, UnixNanos::from(3_000));
1317 assert_eq!(deltas[1].action, BookAction::Add);
1318 assert_eq!(deltas[1].order.price, Price::from("100.0"));
1319 assert_eq!(deltas[2].action, BookAction::Add);
1320 assert_eq!(deltas[2].order.price, Price::from("100.5"));
1321 assert_eq!(deltas[3].action, BookAction::Update);
1322 assert_eq!(deltas[3].order.price, Price::from("100.0"));
1323
1324 std::fs::remove_file(&temp_file).ok();
1325 }
1326
1327 #[rstest]
1328 fn test_load_deltas_returns_empty_when_no_snapshot_present() {
1329 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1332binance-futures,BTCUSDT,1,1,false,bid,99.0,1.0
1333binance-futures,BTCUSDT,2,2,false,ask,101.0,2.0";
1334
1335 let temp_file = std::env::temp_dir().join("test_load_deltas_no_snapshot.csv");
1336 std::fs::write(&temp_file, csv_data).unwrap();
1337
1338 let deltas = load_deltas(&temp_file, Some(1), Some(0), None, None).unwrap();
1339
1340 assert!(deltas.is_empty());
1341
1342 std::fs::remove_file(&temp_file).ok();
1343 }
1344
1345 #[rstest]
1346 fn test_load_deltas_groups_messages_by_local_timestamp() {
1347 let filepath = get_test_data_path("csv/deltas_message_boundaries.csv");
1351 let deltas = load_deltas(filepath, Some(1), Some(1), None, None).unwrap();
1352
1353 assert_eq!(deltas.len(), 6);
1354 assert_eq!(deltas[0].action, BookAction::Clear);
1355 assert_eq!(
1356 deltas.iter().map(|delta| delta.flags).collect::<Vec<_>>(),
1357 vec![
1358 RecordFlag::F_SNAPSHOT as u8, RecordFlag::F_LAST as u8,
1360 0,
1361 RecordFlag::F_LAST as u8,
1362 0,
1363 RecordFlag::F_LAST as u8,
1364 ]
1365 );
1366 assert_eq!(
1367 deltas
1368 .iter()
1369 .map(|delta| delta.ts_event)
1370 .collect::<Vec<_>>(),
1371 vec![
1372 UnixNanos::from(900_000),
1373 UnixNanos::from(900_000),
1374 UnixNanos::from(1_000_000),
1375 UnixNanos::from(1_000_000),
1376 UnixNanos::from(1_000_000),
1377 UnixNanos::from(1_010_000),
1378 ]
1379 );
1380 assert_eq!(
1381 deltas.iter().map(|delta| delta.ts_init).collect::<Vec<_>>(),
1382 vec![
1383 UnixNanos::from(1_900_000),
1384 UnixNanos::from(1_900_000),
1385 UnixNanos::from(2_000_000),
1386 UnixNanos::from(2_000_000),
1387 UnixNanos::from(2_010_000),
1388 UnixNanos::from(2_010_000),
1389 ]
1390 );
1391 assert_eq!(deltas[1].order.side, Some(OrderSide::Buy));
1392 assert_eq!(deltas[1].order.price, Price::from("98.0"));
1393 assert_eq!(deltas[1].order.size, Quantity::from("9.0"));
1394 assert_eq!(deltas[2].order.side, Some(OrderSide::Buy));
1395 assert_eq!(deltas[2].order.price, Price::from("100.0"));
1396 assert_eq!(deltas[2].order.size, Quantity::from("1.0"));
1397 assert_eq!(deltas[3].order.side, Some(OrderSide::Sell));
1398 assert_eq!(deltas[3].order.price, Price::from("101.0"));
1399 assert_eq!(deltas[3].order.size, Quantity::from("2.0"));
1400 assert_eq!(deltas[4].order.side, Some(OrderSide::Buy));
1401 assert_eq!(deltas[4].order.price, Price::from("99.0"));
1402 assert_eq!(deltas[4].order.size, Quantity::from("3.0"));
1403 assert_eq!(deltas[5].order.side, Some(OrderSide::Sell));
1404 assert_eq!(deltas[5].order.price, Price::from("102.0"));
1405 assert_eq!(deltas[5].order.size, Quantity::from("4.0"));
1406 }
1407
1408 #[rstest]
1409 fn test_load_funding_rates_okex_xperp() {
1410 let filepath = get_test_data_path("csv/okex_futures_xperp_derivative_ticker.csv");
1411 let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1412
1413 let instrument_id = InstrumentId::from("BTC-USD_UM_XPERP-310404.OKEX");
1414
1415 assert_eq!(funding_rates.len(), 8);
1416 assert!(
1417 funding_rates
1418 .iter()
1419 .all(|f| f.instrument_id == instrument_id)
1420 );
1421
1422 assert!(
1425 funding_rates
1426 .iter()
1427 .all(|f| f.next_funding_ns.is_some() && f.interval.is_none())
1428 );
1429
1430 let first = &funding_rates[0];
1431 let rolled = &funding_rates[3];
1432
1433 assert_eq!(first.rate, dec!(-0.0003972900658902));
1434 assert_eq!(
1435 first.next_funding_ns,
1436 Some(UnixNanos::from(1_786_320_000_000_000_000))
1437 );
1438 assert_eq!(first.ts_event, UnixNanos::from(1_786_320_006_952_000_000));
1439 assert_eq!(first.ts_init, UnixNanos::from(1_786_320_006_971_532_000));
1440
1441 assert_eq!(rolled.rate, dec!(-0.0003962534258591));
1442 assert_eq!(
1443 rolled.next_funding_ns,
1444 Some(UnixNanos::from(1_786_348_800_000_000_000))
1445 );
1446 assert_eq!(rolled.ts_event, UnixNanos::from(1_786_320_007_369_000_000));
1447 assert_eq!(rolled.ts_init, UnixNanos::from(1_786_320_007_402_423_000));
1448 }
1449
1450 #[rstest]
1451 fn test_load_funding_rates_without_funding_timestamp() {
1452 let filepath = get_test_data_path("csv/deribit_derivative_ticker.csv");
1453 let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1454
1455 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1456
1457 assert_eq!(funding_rates.len(), 3);
1458 assert!(
1459 funding_rates
1460 .iter()
1461 .all(|f| f.instrument_id == instrument_id)
1462 );
1463
1464 assert!(
1466 funding_rates
1467 .iter()
1468 .all(|f| f.next_funding_ns.is_none() && f.interval.is_none())
1469 );
1470
1471 let first = &funding_rates[0];
1472 let changed = &funding_rates[2];
1473
1474 assert_eq!(first.rate, dec!(0.00000459));
1475 assert_eq!(first.ts_event, UnixNanos::from(1_786_320_665_523_000_000));
1476 assert_eq!(first.ts_init, UnixNanos::from(1_786_320_665_533_324_000));
1477
1478 assert_eq!(changed.rate, dec!(0.00000452));
1479 assert_eq!(changed.ts_event, UnixNanos::from(1_786_320_665_645_000_000));
1480 assert_eq!(changed.ts_init, UnixNanos::from(1_786_320_665_661_106_000));
1481 }
1482
1483 #[rstest]
1484 fn test_load_funding_rates_okex_usdc_across_index_migration() {
1485 let filepath = get_test_data_path("csv/okex_swap_usdc_index_migration.csv");
1486 let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1487
1488 let instrument_id = InstrumentId::from("BTC-USDC-SWAP.OKEX");
1489
1490 assert_eq!(funding_rates.len(), 6);
1491
1492 assert!(
1496 funding_rates
1497 .iter()
1498 .all(|f| f.instrument_id == instrument_id)
1499 );
1500
1501 let pre = &funding_rates[0];
1502 let post = &funding_rates[3];
1503
1504 assert_eq!(pre.rate, dec!(0.0001035718476117));
1505 assert_eq!(
1506 pre.next_funding_ns,
1507 Some(UnixNanos::from(1_680_336_000_000_000_000))
1508 );
1509 assert_eq!(pre.ts_event, UnixNanos::from(1_680_309_048_427_000_000));
1510 assert_eq!(pre.ts_init, UnixNanos::from(1_680_309_048_450_728_000));
1511
1512 assert_eq!(post.rate, dec!(-0.000055804472025));
1513 assert_eq!(
1514 post.next_funding_ns,
1515 Some(UnixNanos::from(1_682_928_000_000_000_000))
1516 );
1517 assert_eq!(post.ts_event, UnixNanos::from(1_682_900_658_676_000_000));
1518 assert_eq!(post.ts_init, UnixNanos::from(1_682_900_658_698_855_000));
1519 }
1520
1521 #[rstest]
1522 fn test_load_depth_from_snapshot5_comprehensive() {
1523 let filepath = get_tardis_binance_snapshot5_path();
1524 let depths = load_depth_from_snapshot5(&filepath, None, None, None, Some(100)).unwrap();
1525
1526 assert_eq!(depths.len(), 10);
1527
1528 let first = &depths[0];
1529 assert_eq!(first.instrument_id.to_string(), "BTCUSDT.BINANCE");
1530 assert_eq!(first.bids.len(), 5);
1531 assert_eq!(first.asks.len(), 5);
1532
1533 assert_eq!(first.bids[0].price, Price::from("11657.07"));
1535 assert_eq!(first.bids[0].size, Quantity::from("10.896"));
1536 assert_eq!(first.bids[0].side, OrderSide::Buy.into());
1537
1538 assert_eq!(first.bids[1].price, Price::from("11656.97"));
1539 assert_eq!(first.bids[1].size, Quantity::from("0.2"));
1540 assert_eq!(first.bids[1].side, OrderSide::Buy.into());
1541
1542 assert_eq!(first.bids[2].price, Price::from("11655.78"));
1543 assert_eq!(first.bids[2].size, Quantity::from("0.2"));
1544 assert_eq!(first.bids[2].side, OrderSide::Buy.into());
1545
1546 assert_eq!(first.bids[3].price, Price::from("11655.77"));
1547 assert_eq!(first.bids[3].size, Quantity::from("0.98"));
1548 assert_eq!(first.bids[3].side, OrderSide::Buy.into());
1549
1550 assert_eq!(first.bids[4].price, Price::from("11655.68"));
1551 assert_eq!(first.bids[4].size, Quantity::from("0.111"));
1552 assert_eq!(first.bids[4].side, OrderSide::Buy.into());
1553
1554 assert_eq!(first.asks[0].price, Price::from("11657.08"));
1556 assert_eq!(first.asks[0].size, Quantity::from("1.714"));
1557 assert_eq!(first.asks[0].side, OrderSide::Sell.into());
1558
1559 assert_eq!(first.asks[1].price, Price::from("11657.54"));
1560 assert_eq!(first.asks[1].size, Quantity::from("5.4"));
1561 assert_eq!(first.asks[1].side, OrderSide::Sell.into());
1562
1563 assert_eq!(first.asks[2].price, Price::from("11657.56"));
1564 assert_eq!(first.asks[2].size, Quantity::from("0.238"));
1565 assert_eq!(first.asks[2].side, OrderSide::Sell.into());
1566
1567 assert_eq!(first.asks[3].price, Price::from("11657.61"));
1568 assert_eq!(first.asks[3].size, Quantity::from("0.077"));
1569 assert_eq!(first.asks[3].side, OrderSide::Sell.into());
1570
1571 assert_eq!(first.asks[4].price, Price::from("11657.92"));
1572 assert_eq!(first.asks[4].size, Quantity::from("0.918"));
1573 assert_eq!(first.asks[4].side, OrderSide::Sell.into());
1574
1575 for i in 1..5 {
1577 assert!(
1578 first.bids[i].price < first.bids[i - 1].price,
1579 "Bid price at level {} should be less than level {}",
1580 i,
1581 i - 1
1582 );
1583 }
1584
1585 for i in 1..5 {
1587 assert!(
1588 first.asks[i].price > first.asks[i - 1].price,
1589 "Ask price at level {} should be greater than level {}",
1590 i,
1591 i - 1
1592 );
1593 }
1594
1595 assert!(
1597 first.asks[0].price > first.bids[0].price,
1598 "Best ask should be greater than best bid"
1599 );
1600
1601 assert_eq!(first.bid_counts.as_slice(), &[1; 5]);
1602 assert_eq!(first.ask_counts.as_slice(), &[1; 5]);
1603 for order in first.bids.iter().chain(&first.asks) {
1604 assert_eq!(order.order_id, 0);
1605 }
1606
1607 assert_eq!(
1609 first.flags,
1610 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1611 );
1612 assert_eq!(first.ts_event.as_u64(), 1598918403696000000);
1613 assert_eq!(first.ts_init.as_u64(), 1598918403810979000);
1614 assert_eq!(first.sequence, 0);
1615 }
1616
1617 #[rstest]
1618 fn test_load_depth_from_snapshot25_comprehensive() {
1619 let filepath = get_tardis_binance_snapshot25_path();
1620 let depths = load_depth_from_snapshot25(&filepath, None, None, None, Some(100)).unwrap();
1621
1622 assert_eq!(depths.len(), 10);
1623
1624 let first = &depths[0];
1625 assert_eq!(first.instrument_id.to_string(), "BTCUSDT.BINANCE");
1626 assert_eq!(first.bids.len(), 25);
1627 assert_eq!(first.asks.len(), 25);
1628
1629 let expected_bids = vec![
1631 ("11657.07", "10.896"),
1632 ("11656.97", "0.2"),
1633 ("11655.78", "0.2"),
1634 ("11655.77", "0.98"),
1635 ("11655.68", "0.111"),
1636 ("11655.66", "0.077"),
1637 ("11655.57", "0.34"),
1638 ("11655.48", "0.4"),
1639 ("11655.26", "1.185"),
1640 ("11654.86", "0.195"),
1641 ("11654.85", "0.275"),
1642 ("11654.7", "0.175"),
1643 ("11654.69", "0.194"),
1644 ("11654.67", "1"),
1645 ("11654.65", "0.05"),
1646 ("11654.58", "0.05"),
1647 ("11654.41", "0.11"),
1648 ("11654.28", "0.618"),
1649 ("11653.84", "0.135"),
1650 ("11653.4", "0.17"),
1651 ("11653.39", "1.008"),
1652 ("11653.35", "4"),
1653 ("11653.34", "2"),
1654 ("11653.32", "0.5"),
1655 ("11653.25", "1.003"),
1656 ];
1657
1658 for (i, (price, size)) in expected_bids.iter().enumerate() {
1659 assert_eq!(first.bids[i].price, Price::from(*price));
1660 assert_eq!(first.bids[i].size, Quantity::from(*size));
1661 assert_eq!(first.bids[i].side, OrderSide::Buy.into());
1662 }
1663
1664 let expected_asks = vec![
1666 ("11657.08", "1.714"),
1667 ("11657.54", "5.4"),
1668 ("11657.56", "0.238"),
1669 ("11657.61", "0.077"),
1670 ("11657.92", "0.918"),
1671 ("11658.09", "1.015"),
1672 ("11658.12", "0.665"),
1673 ("11658.19", "0.583"),
1674 ("11658.28", "0.255"),
1675 ("11658.29", "0.656"),
1676 ("11658.64", "1.463"),
1677 ("11658.71", "0.155"),
1678 ("11658.75", "0.155"),
1679 ("11658.88", "0.625"),
1680 ("11658.94", "0.155"),
1681 ("11658.98", "1.005"),
1682 ("11658.99", "0.155"),
1683 ("11659", "1.922"),
1684 ("11659.02", "0.001"),
1685 ("11659.13", "0.11"),
1686 ("11659.17", "0.144"),
1687 ("11659.18", "0.665"),
1688 ("11659.22", "0.22"),
1689 ("11659.28", "0.06"),
1690 ("11659.34", "0.618"),
1691 ];
1692
1693 for (i, (price, size)) in expected_asks.iter().enumerate() {
1694 assert_eq!(first.asks[i].price, Price::from(*price));
1695 assert_eq!(first.asks[i].size, Quantity::from(*size));
1696 assert_eq!(first.asks[i].side, OrderSide::Sell.into());
1697 }
1698
1699 for i in 1..25 {
1701 assert!(
1702 first.bids[i].price < first.bids[i - 1].price,
1703 "Bid price at level {} ({}) should be less than level {} ({})",
1704 i,
1705 first.bids[i].price,
1706 i - 1,
1707 first.bids[i - 1].price
1708 );
1709 }
1710
1711 for i in 1..25 {
1713 assert!(
1714 first.asks[i].price > first.asks[i - 1].price,
1715 "Ask price at level {} ({}) should be greater than level {} ({})",
1716 i,
1717 first.asks[i].price,
1718 i - 1,
1719 first.asks[i - 1].price
1720 );
1721 }
1722
1723 assert!(
1725 first.asks[0].price > first.bids[0].price,
1726 "Best ask ({}) should be greater than best bid ({})",
1727 first.asks[0].price,
1728 first.bids[0].price
1729 );
1730
1731 for i in 0..25 {
1733 assert_eq!(first.bid_counts[i], 1);
1734 assert_eq!(first.ask_counts[i], 1);
1735 }
1736
1737 assert_eq!(
1739 first.flags,
1740 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1741 );
1742 assert_eq!(first.ts_event.as_u64(), 1598918403696000000);
1743 assert_eq!(first.ts_init.as_u64(), 1598918403810979000);
1744 assert_eq!(first.sequence, 0);
1745 }
1746
1747 fn write_precision_rewrite_csv(path: &std::path::Path, levels: usize) {
1751 let mut headers = vec![
1752 "exchange".to_string(),
1753 "symbol".to_string(),
1754 "timestamp".to_string(),
1755 "local_timestamp".to_string(),
1756 ];
1757
1758 for i in 0..levels {
1759 headers.extend([
1760 format!("asks[{i}].price"),
1761 format!("asks[{i}].amount"),
1762 format!("bids[{i}].price"),
1763 format!("bids[{i}].amount"),
1764 ]);
1765 }
1766
1767 let row = |timestamp: &str, best_bid: &str| -> String {
1768 let mut fields = vec![
1769 "binance-futures".to_string(),
1770 "BTCUSDT".to_string(),
1771 timestamp.to_string(),
1772 timestamp.to_string(),
1773 ];
1774
1775 for i in 0..levels {
1776 let bid = if i == 0 {
1777 best_bid.to_string()
1778 } else {
1779 format!("{}", 49_990 - i)
1780 };
1781 fields.extend([
1782 format!("{}", 50_001 + i),
1783 "1.5".to_string(),
1784 bid,
1785 "1.5".to_string(),
1786 ]);
1787 }
1788 fields.join(",")
1789 };
1790
1791 let csv_data = format!(
1792 "{}\n{}\n{}",
1793 headers.join(","),
1794 row("1640995200000000", "49999"),
1795 row("1640995201000000", "49998.12"),
1796 );
1797 std::fs::write(path, csv_data).unwrap();
1798 }
1799
1800 #[rstest]
1801 fn test_load_depth_from_snapshot25_precision_rewrite_updates_all_levels() {
1802 let temp_file = std::env::temp_dir().join("test_depth_snapshot25_precision_rewrite.csv");
1803 write_precision_rewrite_csv(&temp_file, 25);
1804
1805 let depths = load_depth_from_snapshot25(&temp_file, None, None, None, None).unwrap();
1806 assert_eq!(depths.len(), 2);
1807
1808 for (r, depth) in depths.iter().enumerate() {
1811 assert_eq!(depth.bids.len(), 25, "row {r}");
1812 assert_eq!(depth.asks.len(), 25, "row {r}");
1813 for (i, order) in depth.bids.iter().chain(depth.asks.iter()).enumerate() {
1814 assert_eq!(order.price.precision, 2, "row {r} level {i}");
1815 assert_eq!(order.size.precision, 1, "row {r} level {i}");
1816 }
1817 }
1818 assert_eq!(depths[0].bids[0].price, Price::new(49999.0, 2));
1820 assert_eq!(depths[0].bids[24].price, Price::new(49966.0, 2));
1821 assert_eq!(depths[1].bids[0].price, Price::new(49998.12, 2));
1822
1823 std::fs::remove_file(&temp_file).ok();
1824 }
1825
1826 #[rstest]
1827 fn test_load_depth_from_snapshot5_precision_rewrite_updates_all_levels() {
1828 let temp_file = std::env::temp_dir().join("test_depth_snapshot5_precision_rewrite.csv");
1829 write_precision_rewrite_csv(&temp_file, 5);
1830
1831 let depths = load_depth_from_snapshot5(&temp_file, None, None, None, None).unwrap();
1832 assert_eq!(depths.len(), 2);
1833
1834 for (r, depth) in depths.iter().enumerate() {
1836 assert_eq!(depth.bids.len(), 5, "row {r}");
1837 assert_eq!(depth.asks.len(), 5, "row {r}");
1838 for (i, order) in depth.bids.iter().chain(depth.asks.iter()).enumerate() {
1839 assert_eq!(order.price.precision, 2, "row {r} level {i}");
1840 assert_eq!(order.size.precision, 1, "row {r} level {i}");
1841 }
1842 }
1843 assert_eq!(depths[0].bids[0].price, Price::new(49999.0, 2));
1844 assert_eq!(depths[1].bids[0].price, Price::new(49998.12, 2));
1845
1846 std::fs::remove_file(&temp_file).ok();
1847 }
1848
1849 #[rstest]
1850 fn test_snapshot_csv_field_order_interleaved() {
1851 let csv_data = "exchange,symbol,timestamp,local_timestamp,\
1855asks[0].price,asks[0].amount,bids[0].price,bids[0].amount,\
1856asks[1].price,asks[1].amount,bids[1].price,bids[1].amount,\
1857asks[2].price,asks[2].amount,bids[2].price,bids[2].amount,\
1858asks[3].price,asks[3].amount,bids[3].price,bids[3].amount,\
1859asks[4].price,asks[4].amount,bids[4].price,bids[4].amount
1860binance-futures,BTCUSDT,1000000,2000000,\
1861100.5,1.0,100.4,2.0,\
1862100.6,1.1,100.3,2.1,\
1863100.7,1.2,100.2,2.2,\
1864100.8,1.3,100.1,2.3,\
1865100.9,1.4,100.0,2.4";
1866
1867 let temp_file = std::env::temp_dir().join("test_interleaved_snapshot5.csv");
1868 std::fs::write(&temp_file, csv_data).unwrap();
1869
1870 let depths = load_depth_from_snapshot5(&temp_file, None, None, None, Some(1)).unwrap();
1871 assert_eq!(depths.len(), 1);
1872
1873 let depth = &depths[0];
1874
1875 assert_eq!(depth.bids[0].price, Price::from("100.4"));
1877 assert_eq!(depth.bids[1].price, Price::from("100.3"));
1878 assert_eq!(depth.bids[2].price, Price::from("100.2"));
1879 assert_eq!(depth.bids[3].price, Price::from("100.1"));
1880 assert_eq!(depth.bids[4].price, Price::from("100.0"));
1881
1882 assert_eq!(depth.asks[0].price, Price::from("100.5"));
1884 assert_eq!(depth.asks[1].price, Price::from("100.6"));
1885 assert_eq!(depth.asks[2].price, Price::from("100.7"));
1886 assert_eq!(depth.asks[3].price, Price::from("100.8"));
1887 assert_eq!(depth.asks[4].price, Price::from("100.9"));
1888
1889 assert_eq!(depth.bids[0].size, Quantity::from("2.0"));
1891 assert_eq!(depth.asks[0].size, Quantity::from("1.0"));
1892
1893 std::fs::remove_file(temp_file).unwrap();
1894 }
1895
1896 #[rstest]
1897 fn test_load_deltas_limit_includes_clear_deltas() {
1898 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1901binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1902binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
1903binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
1904binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
1905binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49998.0,0.5
1906binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50003.0,2.0
1907binance-futures,BTCUSDT,1640995205000000,1640995205100000,false,bid,49997.0,0.5";
1908
1909 let temp_file = std::env::temp_dir().join("test_load_deltas_limit.csv");
1910 std::fs::write(&temp_file, csv_data).unwrap();
1911
1912 let deltas = load_deltas(&temp_file, Some(1), Some(1), None, Some(5)).unwrap();
1914
1915 assert_eq!(deltas.len(), 5);
1917 assert_eq!(deltas[0].action, BookAction::Clear);
1918 assert_eq!(deltas[1].action, BookAction::Add);
1919 assert_eq!(deltas[2].action, BookAction::Add);
1920 assert_eq!(deltas[3].action, BookAction::Update);
1921 assert_eq!(deltas[4].action, BookAction::Update);
1922
1923 assert_eq!(deltas[3].order.price, parse_price(49999.0, 1));
1925
1926 std::fs::remove_file(&temp_file).ok();
1927 }
1928
1929 #[rstest]
1930 fn test_load_deltas_limit_stops_at_clear() {
1931 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1933binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1934binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0";
1935
1936 let temp_file = std::env::temp_dir().join("test_load_deltas_limit_stops_at_clear.csv");
1937 std::fs::write(&temp_file, csv_data).unwrap();
1938
1939 let deltas = load_deltas(&temp_file, Some(1), Some(1), None, Some(1)).unwrap();
1941
1942 assert_eq!(deltas.len(), 1);
1943 assert_eq!(deltas[0].action, BookAction::Clear);
1944
1945 std::fs::remove_file(&temp_file).ok();
1946 }
1947
1948 #[rstest]
1949 fn test_load_deltas_with_consecutive_snapshots_inserts_clear() {
1950 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1951hyperliquid,BTC,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1952hyperliquid,BTC,1640995200000001,1640995200100000,true,ask,50001.0,2.0
1953hyperliquid,BTC,1640995201000000,1640995201100000,true,bid,49990.0,3.0
1954hyperliquid,BTC,1640995201000001,1640995201100000,true,ask,49991.0,4.0";
1955
1956 let temp_file = std::env::temp_dir().join("test_load_deltas_consecutive_snapshots.csv");
1957 std::fs::write(&temp_file, csv_data).unwrap();
1958
1959 let deltas = load_deltas(&temp_file, Some(1), Some(1), None, None).unwrap();
1960 let clear_count = deltas
1961 .iter()
1962 .filter(|d| d.action == BookAction::Clear)
1963 .count();
1964
1965 assert_eq!(clear_count, 2);
1966 assert_eq!(deltas[0].action, BookAction::Clear);
1967 assert_eq!(deltas[3].action, BookAction::Clear);
1968 assert_eq!(
1969 deltas[2].flags & RecordFlag::F_LAST as u8,
1970 RecordFlag::F_LAST as u8
1971 );
1972 assert_eq!(deltas[3].flags & RecordFlag::F_LAST as u8, 0);
1973 assert_eq!(
1974 deltas
1975 .iter()
1976 .map(|delta| (delta.action, delta.flags, delta.ts_event, delta.ts_init))
1977 .collect::<Vec<_>>(),
1978 vec![
1979 (
1980 BookAction::Clear,
1981 RecordFlag::F_SNAPSHOT as u8,
1982 UnixNanos::from(1_640_995_200_000_000_000),
1983 UnixNanos::from(1_640_995_200_100_000_000),
1984 ),
1985 (
1986 BookAction::Add,
1987 0,
1988 UnixNanos::from(1_640_995_200_000_000_000),
1989 UnixNanos::from(1_640_995_200_100_000_000),
1990 ),
1991 (
1992 BookAction::Add,
1993 RecordFlag::F_LAST as u8,
1994 UnixNanos::from(1_640_995_200_000_001_000),
1995 UnixNanos::from(1_640_995_200_100_000_000),
1996 ),
1997 (
1998 BookAction::Clear,
1999 RecordFlag::F_SNAPSHOT as u8,
2000 UnixNanos::from(1_640_995_201_000_000_000),
2001 UnixNanos::from(1_640_995_201_100_000_000),
2002 ),
2003 (
2004 BookAction::Add,
2005 0,
2006 UnixNanos::from(1_640_995_201_000_000_000),
2007 UnixNanos::from(1_640_995_201_100_000_000),
2008 ),
2009 (
2010 BookAction::Add,
2011 RecordFlag::F_LAST as u8,
2012 UnixNanos::from(1_640_995_201_000_001_000),
2013 UnixNanos::from(1_640_995_201_100_000_000),
2014 ),
2015 ]
2016 );
2017
2018 std::fs::remove_file(&temp_file).ok();
2019 }
2020
2021 #[rstest]
2022 fn test_load_deltas_limit_with_mid_day_snapshot() {
2023 let filepath = get_test_data_path("csv/deltas_with_snapshot.csv");
2026 let deltas = load_deltas(filepath, Some(1), Some(1), None, Some(5)).unwrap();
2027
2028 assert_eq!(deltas.len(), 5);
2031 assert_eq!(deltas[0].action, BookAction::Clear);
2032 }
2033
2034 #[cfg(feature = "arrow")]
2037 #[rstest]
2038 #[ignore = "one-time dataset curation, not for routine CI"]
2039 fn test_curate_deribit_deltas() {
2040 let csv_path = get_test_data_root()
2041 .join("large")
2042 .join("tardis_deribit_incremental_book_L2_2020-04-01_BTC-PERPETUAL.csv.gz");
2043
2044 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
2045 let parquet_path = "/tmp/tardis_BTC-PERPETUAL.DERIBIT_2020-04-01_deltas.parquet";
2046
2047 println!("Loading deltas from {}", csv_path.display());
2048 let deltas = load_deltas(&csv_path, None, None, Some(instrument_id), None).unwrap();
2049 let count = deltas.len();
2050 println!("Loaded {count} deltas");
2051
2052 let sample = deltas
2053 .iter()
2054 .find(|d| d.order.price.precision > 0)
2055 .expect("Should have at least one non-CLEAR delta");
2056 let price_precision = sample.order.price.precision;
2057 let size_precision = sample.order.size.precision;
2058 println!("Precision: price={price_precision}, size={size_precision}");
2059
2060 let metadata =
2062 OrderBookDelta::get_metadata(&instrument_id, price_precision, size_precision);
2063 let schema = OrderBookDelta::get_schema(Some(metadata.clone()));
2064
2065 println!("Writing Parquet to {parquet_path}");
2066 let file = File::create(parquet_path).unwrap();
2067 let zstd_level = parquet::basic::ZstdLevel::try_new(3).unwrap();
2068 let props = WriterProperties::builder()
2069 .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
2070 .set_max_row_group_row_count(Some(1_000_000))
2071 .build();
2072 let mut writer = ArrowWriter::try_new(file, Arc::new(schema), Some(props)).unwrap();
2073
2074 let chunk_size = 1_000_000;
2075 for (i, chunk) in deltas.chunks(chunk_size).enumerate() {
2076 println!(" Encoding chunk {} ({} records)...", i + 1, chunk.len());
2077 let batch = OrderBookDelta::encode_batch(&metadata, chunk).unwrap();
2078 writer.write(&batch).unwrap();
2079 }
2080 writer.close().unwrap();
2081
2082 let file_size = fs::metadata(parquet_path).unwrap().len();
2083 println!("\n=== CURATION COMPLETE ===");
2084 println!("Records: {count}");
2085 println!("Price precision: {price_precision}");
2086 println!("Size precision: {size_precision}");
2087 println!(
2088 "File size: {} bytes ({:.1} MB)",
2089 file_size,
2090 file_size as f64 / 1_048_576.0
2091 );
2092 println!("Output: {parquet_path}");
2093 println!("\nNext steps:");
2094 println!(" sha256sum {parquet_path}");
2095 }
2096}