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
185 let mut reader = create_csv_reader(filepath)?;
186 let mut record = StringRecord::new();
187
188 while reader.read_record(&mut record)? {
189 if let Some(limit) = limit
190 && deltas.len() >= limit
191 {
192 break;
193 }
194
195 let data: TardisBookUpdateRecord = record.deserialize(None)?;
196
197 update_precision_if_needed(&mut current_price_precision, data.price, price_precision);
198 update_precision_if_needed(&mut current_size_precision, data.amount, size_precision);
199
200 let ts_event = parse_timestamp(data.timestamp);
201 let ts_init = parse_timestamp(data.local_timestamp);
202
203 let starts_new_snapshot =
207 data.is_snapshot && (!last_is_snapshot || last_ts_init != Some(ts_init));
208
209 if starts_new_snapshot {
210 let clear_instrument_id =
211 instrument_id.unwrap_or_else(|| parse_instrument_id(&data.exchange, data.symbol));
212
213 if last_ts_init != Some(ts_init)
214 && let Some(last_delta) = deltas.last_mut()
215 {
216 last_delta.flags = RecordFlag::F_LAST as u8;
217 }
218 last_ts_init = Some(ts_init);
219
220 let clear_delta = OrderBookDelta::clear(clear_instrument_id, 0, ts_event, ts_init);
221 deltas.push(clear_delta);
222
223 if let Some(limit) = limit
224 && deltas.len() >= limit
225 {
226 break;
227 }
228 }
229 last_is_snapshot = data.is_snapshot;
230
231 let delta = match parse_delta_record(
232 &data,
233 current_price_precision,
234 current_size_precision,
235 instrument_id,
236 ) {
237 Ok(d) => d,
238 Err(e) => {
239 log::warn!("Skipping invalid delta record: {e}");
240 continue;
241 }
242 };
243
244 let ts_init = delta.ts_init;
245 if last_ts_init != Some(ts_init)
246 && let Some(last_delta) = deltas.last_mut()
247 {
248 last_delta.flags = RecordFlag::F_LAST as u8;
249 }
250
251 last_ts_init = Some(ts_init);
252
253 deltas.push(delta);
254 }
255
256 if let Some(last_delta) = deltas.last_mut() {
258 last_delta.flags = RecordFlag::F_LAST as u8;
259 }
260
261 update_deltas_precision(
264 &mut deltas,
265 price_precision,
266 size_precision,
267 current_price_precision,
268 current_size_precision,
269 );
270
271 Ok(deltas)
272}
273
274pub fn load_depth_from_snapshot5<P: AsRef<Path>>(
286 filepath: P,
287 price_precision: Option<u8>,
288 size_precision: Option<u8>,
289 instrument_id: Option<InstrumentId>,
290 limit: Option<usize>,
291) -> Result<Vec<OrderBookDepth>, Box<dyn Error>> {
292 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
294 let mut depths: Vec<OrderBookDepth> = Vec::with_capacity(estimated_capacity);
295
296 let mut current_price_precision = price_precision.unwrap_or(0);
297 let mut current_size_precision = size_precision.unwrap_or(0);
298
299 let mut reader = create_csv_reader(filepath)?;
300 let mut record = StringRecord::new();
301
302 while reader.read_record(&mut record)? {
303 let data: TardisOrderBookSnapshot5Record = record.deserialize(None)?;
304
305 let mut precision_updated = false;
307
308 if price_precision.is_none()
309 && let Some(bid_price) = data.bids_0_price
310 {
311 let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
312 if inferred_price_precision > current_price_precision {
313 current_price_precision = inferred_price_precision;
314 precision_updated = true;
315 }
316 }
317
318 if size_precision.is_none()
319 && let Some(bid_amount) = data.bids_0_amount
320 {
321 let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
322 if inferred_size_precision > current_size_precision {
323 current_size_precision = inferred_size_precision;
324 precision_updated = true;
325 }
326 }
327
328 if precision_updated {
330 for depth in &mut depths {
331 for order in depth.bids.iter_mut().chain(depth.asks.iter_mut()) {
332 if price_precision.is_none() {
333 order.price.precision = current_price_precision;
334 }
335
336 if size_precision.is_none() {
337 order.size.precision = current_size_precision;
338 }
339 }
340 }
341 }
342
343 let instrument_id = match &instrument_id {
344 Some(id) => *id,
345 None => parse_instrument_id(&data.exchange, data.symbol),
346 };
347 let flags = RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8;
349 let sequence = 0; let ts_event = parse_timestamp(data.timestamp);
351 let ts_init = parse_timestamp(data.local_timestamp);
352
353 let mut bids = [NULL_ORDER; DEPTH10_LEN];
355 let mut asks = [NULL_ORDER; DEPTH10_LEN];
356 let mut bid_counts = [0u32; DEPTH10_LEN];
357 let mut ask_counts = [0u32; DEPTH10_LEN];
358
359 for i in 0..=4 {
360 let (bid_order, bid_count) = create_book_order(
362 OrderSide::Buy,
363 match i {
364 0 => data.bids_0_price,
365 1 => data.bids_1_price,
366 2 => data.bids_2_price,
367 3 => data.bids_3_price,
368 4 => data.bids_4_price,
369 _ => unreachable!("i is constrained to 0..=4 by loop"),
370 },
371 match i {
372 0 => data.bids_0_amount,
373 1 => data.bids_1_amount,
374 2 => data.bids_2_amount,
375 3 => data.bids_3_amount,
376 4 => data.bids_4_amount,
377 _ => unreachable!("i is constrained to 0..=4 by loop"),
378 },
379 current_price_precision,
380 current_size_precision,
381 );
382 bids[i] = bid_order;
383 bid_counts[i] = bid_count;
384
385 let (ask_order, ask_count) = create_book_order(
387 OrderSide::Sell,
388 match i {
389 0 => data.asks_0_price,
390 1 => data.asks_1_price,
391 2 => data.asks_2_price,
392 3 => data.asks_3_price,
393 4 => data.asks_4_price,
394 _ => None, },
396 match i {
397 0 => data.asks_0_amount,
398 1 => data.asks_1_amount,
399 2 => data.asks_2_amount,
400 3 => data.asks_3_amount,
401 4 => data.asks_4_amount,
402 _ => None, },
404 current_price_precision,
405 current_size_precision,
406 );
407 asks[i] = ask_order;
408 ask_counts[i] = ask_count;
409 }
410
411 let depth = OrderBookDepth::new(
412 instrument_id,
413 bids,
414 asks,
415 bid_counts,
416 ask_counts,
417 flags,
418 sequence,
419 ts_event,
420 ts_init,
421 );
422
423 depths.push(depth);
424
425 if let Some(limit) = limit
426 && depths.len() >= limit
427 {
428 break;
429 }
430 }
431
432 Ok(depths)
433}
434
435pub fn load_depth_from_snapshot25<P: AsRef<Path>>(
443 filepath: P,
444 price_precision: Option<u8>,
445 size_precision: Option<u8>,
446 instrument_id: Option<InstrumentId>,
447 limit: Option<usize>,
448) -> Result<Vec<OrderBookDepth>, Box<dyn Error>> {
449 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
451 let mut depths: Vec<OrderBookDepth> = Vec::with_capacity(estimated_capacity);
452
453 let mut current_price_precision = price_precision.unwrap_or(0);
454 let mut current_size_precision = size_precision.unwrap_or(0);
455 let mut reader = create_csv_reader(filepath)?;
456 let mut record = StringRecord::new();
457
458 while reader.read_record(&mut record)? {
459 let data: TardisOrderBookSnapshot25Record = record.deserialize(None)?;
460
461 let mut precision_updated = false;
463
464 if price_precision.is_none()
465 && let Some(bid_price) = data.bids_0_price
466 {
467 let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
468 if inferred_price_precision > current_price_precision {
469 current_price_precision = inferred_price_precision;
470 precision_updated = true;
471 }
472 }
473
474 if size_precision.is_none()
475 && let Some(bid_amount) = data.bids_0_amount
476 {
477 let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
478 if inferred_size_precision > current_size_precision {
479 current_size_precision = inferred_size_precision;
480 precision_updated = true;
481 }
482 }
483
484 if precision_updated {
486 for depth in &mut depths {
487 for order in depth.bids.iter_mut().chain(depth.asks.iter_mut()) {
488 if price_precision.is_none() {
489 order.price.precision = current_price_precision;
490 }
491
492 if size_precision.is_none() {
493 order.size.precision = current_size_precision;
494 }
495 }
496 }
497 }
498
499 let instrument_id = match &instrument_id {
500 Some(id) => *id,
501 None => parse_instrument_id(&data.exchange, data.symbol),
502 };
503 let flags = RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8;
505 let sequence = 0; let ts_event = parse_timestamp(data.timestamp);
507 let ts_init = parse_timestamp(data.local_timestamp);
508
509 let mut bids = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
511 let mut asks = [NULL_ORDER; TardisOrderBookSnapshot25Record::LEVELS];
512 let mut bid_counts = [0u32; TardisOrderBookSnapshot25Record::LEVELS];
513 let mut ask_counts = [0u32; TardisOrderBookSnapshot25Record::LEVELS];
514
515 for i in 0..TardisOrderBookSnapshot25Record::LEVELS {
517 let (bid_price, bid_amount) = data.bid_level(i);
519 let (bid_order, bid_count) = create_book_order(
520 OrderSide::Buy,
521 bid_price,
522 bid_amount,
523 current_price_precision,
524 current_size_precision,
525 );
526 bids[i] = bid_order;
527 bid_counts[i] = bid_count;
528
529 let (ask_price, ask_amount) = data.ask_level(i);
531 let (ask_order, ask_count) = create_book_order(
532 OrderSide::Sell,
533 ask_price,
534 ask_amount,
535 current_price_precision,
536 current_size_precision,
537 );
538 asks[i] = ask_order;
539 ask_counts[i] = ask_count;
540 }
541
542 let depth = OrderBookDepth::new(
543 instrument_id,
544 bids,
545 asks,
546 bid_counts,
547 ask_counts,
548 flags,
549 sequence,
550 ts_event,
551 ts_init,
552 );
553
554 depths.push(depth);
555
556 if let Some(limit) = limit
557 && depths.len() >= limit
558 {
559 break;
560 }
561 }
562
563 Ok(depths)
564}
565
566pub fn load_quotes<P: AsRef<Path>>(
574 filepath: P,
575 price_precision: Option<u8>,
576 size_precision: Option<u8>,
577 instrument_id: Option<InstrumentId>,
578 limit: Option<usize>,
579) -> Result<Vec<QuoteTick>, Box<dyn Error>> {
580 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
582 let mut quotes: Vec<QuoteTick> = Vec::with_capacity(estimated_capacity);
583
584 let mut current_price_precision = price_precision.unwrap_or(0);
585 let mut current_size_precision = size_precision.unwrap_or(0);
586 let mut reader = create_csv_reader(filepath)?;
587 let mut record = StringRecord::new();
588
589 while reader.read_record(&mut record)? {
590 let data: TardisQuoteRecord = record.deserialize(None)?;
591
592 if price_precision.is_none()
593 && let Some(bid_price) = data.bid_price
594 {
595 let inferred_price_precision = infer_precision(bid_price).min(FIXED_PRECISION);
596 if inferred_price_precision > current_price_precision {
597 current_price_precision = inferred_price_precision;
598 }
599 }
600
601 if size_precision.is_none()
602 && let Some(bid_amount) = data.bid_amount
603 {
604 let inferred_size_precision = infer_precision(bid_amount).min(FIXED_PRECISION);
605 if inferred_size_precision > current_size_precision {
606 current_size_precision = inferred_size_precision;
607 }
608 }
609
610 let quote = parse_quote_record(
611 &data,
612 current_price_precision,
613 current_size_precision,
614 instrument_id,
615 );
616
617 quotes.push(quote);
618
619 if let Some(limit) = limit
620 && quotes.len() >= limit
621 {
622 break;
623 }
624 }
625
626 update_quotes_precision(
629 &mut quotes,
630 price_precision,
631 size_precision,
632 current_price_precision,
633 current_size_precision,
634 );
635
636 Ok(quotes)
637}
638
639pub fn load_trades<P: AsRef<Path>>(
647 filepath: P,
648 price_precision: Option<u8>,
649 size_precision: Option<u8>,
650 instrument_id: Option<InstrumentId>,
651 limit: Option<usize>,
652) -> Result<Vec<TradeTick>, Box<dyn Error>> {
653 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
655 let mut trades: Vec<TradeTick> = Vec::with_capacity(estimated_capacity);
656
657 let mut current_price_precision = price_precision.unwrap_or(0);
658 let mut current_size_precision = size_precision.unwrap_or(0);
659 let mut reader = create_csv_reader(filepath)?;
660 let mut record = StringRecord::new();
661
662 while reader.read_record(&mut record)? {
663 let data: TardisTradeRecord = record.deserialize(None)?;
664
665 if price_precision.is_none() {
666 let inferred_price_precision = infer_precision(data.price).min(FIXED_PRECISION);
667 if inferred_price_precision > current_price_precision {
668 current_price_precision = inferred_price_precision;
669 }
670 }
671
672 if size_precision.is_none() {
673 let inferred_size_precision = infer_precision(data.amount).min(FIXED_PRECISION);
674 if inferred_size_precision > current_size_precision {
675 current_size_precision = inferred_size_precision;
676 }
677 }
678
679 let size = Quantity::new_checked(data.amount, current_size_precision)?;
680
681 if size.is_positive() {
682 let trade = parse_trade_record(&data, size, current_price_precision, instrument_id);
683
684 trades.push(trade);
685
686 if let Some(limit) = limit
687 && trades.len() >= limit
688 {
689 break;
690 }
691 } else {
692 log::warn!("Skipping zero-sized trade: {data:?}");
693 }
694 }
695
696 update_trades_precision(
699 &mut trades,
700 price_precision,
701 size_precision,
702 current_price_precision,
703 current_size_precision,
704 );
705
706 Ok(trades)
707}
708
709pub fn load_funding_rates<P: AsRef<Path>>(
719 filepath: P,
720 instrument_id: Option<InstrumentId>,
721 limit: Option<usize>,
722) -> Result<Vec<FundingRateUpdate>, Box<dyn Error>> {
723 let estimated_capacity = limit.unwrap_or(100_000).min(1_000_000);
725 let mut funding_rates: Vec<FundingRateUpdate> = Vec::with_capacity(estimated_capacity);
726
727 let mut reader = create_csv_reader(filepath)?;
728 let mut record = StringRecord::new();
729
730 while reader.read_record(&mut record)? {
731 let data: TardisDerivativeTickerRecord = record.deserialize(None)?;
732
733 if let Some(funding_rate) = parse_derivative_ticker_record(&data, instrument_id) {
735 funding_rates.push(funding_rate);
736
737 if let Some(limit) = limit
738 && funding_rates.len() >= limit
739 {
740 break;
741 }
742 }
743 }
744
745 Ok(funding_rates)
746}
747
748pub fn load_options_chain<P: AsRef<Path>>(
758 filepath: P,
759 underlyings: Option<Vec<String>>,
760 price_precision: Option<u8>,
761 size_precision: Option<u8>,
762 limit: Option<usize>,
763) -> Result<Vec<Data>, Box<dyn Error>> {
764 let underlyings = normalize_underlying_filters(underlyings);
765 let estimated_capacity = limit.unwrap_or(1_000_000).min(10_000_000);
766 let mut records: Vec<TardisOptionsChainRecord> = Vec::with_capacity(estimated_capacity);
767 let mut precision_by_instrument: AHashMap<InstrumentId, OptionsChainPrecision> =
768 AHashMap::new();
769
770 let mut reader = create_csv_reader(filepath)?;
771 let mut record = StringRecord::new();
772
773 while reader.read_record(&mut record)? {
774 if let Some(underlyings) = underlyings.as_deref() {
775 let Some(symbol) = record.get(1) else {
776 continue;
777 };
778 let symbol = symbol.to_uppercase();
779 if !matches_underlying_filter(&symbol, Some(underlyings)) {
780 continue;
781 }
782 }
783
784 let data: TardisOptionsChainRecord = record.deserialize(None)?;
785 let instrument_id = parse_instrument_id(&data.exchange, data.symbol);
786 precision_by_instrument
787 .entry(instrument_id)
788 .or_insert_with(|| OptionsChainPrecision::new(price_precision, size_precision))
789 .update(&data, price_precision, size_precision);
790 records.push(data);
791
792 if let Some(limit) = limit
793 && records.len() >= limit
794 {
795 break;
796 }
797 }
798
799 let mut output = Vec::with_capacity(records.len() * 2);
800 for record in records {
801 let instrument_id = parse_instrument_id(&record.exchange, record.symbol);
802 let precision = precision_by_instrument
803 .get(&instrument_id)
804 .copied()
805 .unwrap_or_else(|| OptionsChainPrecision::new(price_precision, size_precision));
806
807 if let Some(quote) = parse_options_chain_record_as_quote(
808 &record,
809 precision.price,
810 precision.size,
811 instrument_id,
812 )? {
813 output.push(Data::Quote(quote));
814 }
815
816 output.push(Data::OptionGreeks(parse_options_chain_record(
817 &record,
818 instrument_id,
819 )));
820 }
821
822 Ok(output)
823}
824
825#[cfg(test)]
826mod tests {
827 use std::{fs, fs::File, sync::Arc};
828
829 use nautilus_core::paths::get_test_data_path as get_test_data_root;
830 use nautilus_model::{
831 enums::{AggressorSide, BookAction, OrderSide},
832 identifiers::{InstrumentId, TradeId},
833 types::Price,
834 };
835 use nautilus_serialization::arrow::{ArrowSchemaProvider, EncodeToRecordBatch};
836 use nautilus_testkit::common::{
837 get_tardis_binance_snapshot5_path, get_tardis_binance_snapshot25_path,
838 get_tardis_bitmex_trades_path, get_tardis_deribit_book_l2_path,
839 get_tardis_huobi_quotes_path,
840 };
841 use parquet::{arrow::ArrowWriter, file::properties::WriterProperties};
842 use rstest::*;
843 use rust_decimal_macros::dec;
844
845 use super::*;
846 use crate::common::{parse::parse_price, testing::get_test_data_path};
847
848 #[rstest]
849 #[case(0.0, 0)]
850 #[case(42.0, 0)]
851 #[case(0.1, 1)]
852 #[case(0.25, 2)]
853 #[case(123.0001, 4)]
854 #[case(-42.987654321, 9)]
855 #[case(1.234_567_890_123, 12)]
856 fn test_infer_precision(#[case] input: f64, #[case] expected: u8) {
857 assert_eq!(infer_precision(input), expected);
858 }
859
860 #[rstest]
861 pub fn test_dynamic_precision_inference() {
862 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
863binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50000.0,1.0
864binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.5,2.0
865binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50000.12,1.5
866binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49999.123,3.0
867binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50000.1234,0.5";
868
869 let temp_file = std::env::temp_dir().join("test_dynamic_precision.csv");
870 std::fs::write(&temp_file, csv_data).unwrap();
871
872 let deltas = load_deltas(&temp_file, None, None, None, None).unwrap();
873
874 assert_eq!(deltas.len(), 6);
876
877 for (i, delta) in deltas.iter().skip(1).enumerate() {
879 assert_eq!(
880 delta.order.price.precision, 4,
881 "Price precision should be 4 for delta {i}",
882 );
883 assert_eq!(
884 delta.order.size.precision, 1,
885 "Size precision should be 1 for delta {i}",
886 );
887 }
888
889 assert_eq!(deltas[0].action, BookAction::Clear);
892
893 assert_eq!(deltas[1].order.price, parse_price(50000.0, 4));
894 assert_eq!(deltas[1].order.size, Quantity::new(1.0, 1));
895
896 assert_eq!(deltas[2].order.price, parse_price(49999.5, 4));
897 assert_eq!(deltas[2].order.size, Quantity::new(2.0, 1));
898
899 assert_eq!(deltas[3].order.price, parse_price(50000.12, 4));
900 assert_eq!(deltas[3].order.size, Quantity::new(1.5, 1));
901
902 assert_eq!(deltas[4].order.price, parse_price(49999.123, 4));
903 assert_eq!(deltas[4].order.size, Quantity::new(3.0, 1));
904
905 assert_eq!(deltas[5].order.price, parse_price(50000.1234, 4));
906 assert_eq!(deltas[5].order.size, Quantity::new(0.5, 1));
907
908 assert_eq!(
909 deltas[1].order.price.precision,
910 deltas[5].order.price.precision
911 );
912 assert_eq!(
913 deltas[1].order.size.precision,
914 deltas[3].order.size.precision
915 );
916
917 std::fs::remove_file(&temp_file).ok();
918 }
919
920 #[rstest]
921 #[case(Some(1), Some(0))] #[case(None, None)] pub fn test_read_deltas(
924 #[case] price_precision: Option<u8>,
925 #[case] size_precision: Option<u8>,
926 ) {
927 let filepath = get_tardis_deribit_book_l2_path();
928 let deltas =
929 load_deltas(filepath, price_precision, size_precision, None, Some(100)).unwrap();
930
931 assert_eq!(deltas.len(), 16);
933
934 assert_eq!(deltas[0].action, BookAction::Clear);
936
937 assert_eq!(
939 deltas[1].instrument_id,
940 InstrumentId::from("BTC-PERPETUAL.DERIBIT")
941 );
942 assert_eq!(deltas[1].action, BookAction::Add);
943 assert_eq!(deltas[1].order.side, OrderSide::Sell.into());
944 assert_eq!(deltas[1].order.price, Price::from("6421.5"));
945 assert_eq!(deltas[1].order.size, Quantity::from("18640"));
946 assert_eq!(deltas[1].flags, 0);
947 assert_eq!(deltas[1].sequence, 0);
948 assert_eq!(deltas[1].ts_event, 1585699200245000000);
949 assert_eq!(deltas[1].ts_init, 1585699200355684000);
950 }
951
952 #[rstest]
953 #[case(Some(2), Some(3))] #[case(None, None)] pub fn test_read_depths_from_snapshot5(
956 #[case] price_precision: Option<u8>,
957 #[case] size_precision: Option<u8>,
958 ) {
959 let filepath = get_tardis_binance_snapshot5_path();
960 let depths =
961 load_depth_from_snapshot5(filepath, price_precision, size_precision, None, Some(100))
962 .unwrap();
963
964 assert_eq!(depths.len(), 10);
965 assert_eq!(
966 depths[0].instrument_id,
967 InstrumentId::from("BTCUSDT.BINANCE")
968 );
969 assert_eq!(depths[0].bids.len(), 5);
970 assert_eq!(depths[0].bids[0].price, Price::from("11657.07"));
971 assert_eq!(depths[0].bids[0].size, Quantity::from("10.896"));
972 assert_eq!(depths[0].bids[0].side, OrderSide::Buy.into());
973 assert_eq!(depths[0].bids[0].order_id, 0);
974 assert_eq!(depths[0].asks.len(), 5);
975 assert_eq!(depths[0].asks[0].price, Price::from("11657.08"));
976 assert_eq!(depths[0].asks[0].size, Quantity::from("1.714"));
977 assert_eq!(depths[0].asks[0].side, OrderSide::Sell.into());
978 assert_eq!(depths[0].asks[0].order_id, 0);
979 assert_eq!(depths[0].bid_counts.as_slice(), &[1; 5]);
980 assert_eq!(depths[0].ask_counts.as_slice(), &[1; 5]);
981 assert_eq!(
983 depths[0].flags,
984 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
985 );
986 assert_eq!(depths[0].ts_event, 1598918403696000000);
987 assert_eq!(depths[0].ts_init, 1598918403810979000);
988 assert_eq!(depths[0].sequence, 0);
989 }
990
991 #[rstest]
992 #[case(Some(2), Some(3))] #[case(None, None)] pub fn test_read_depths_from_snapshot25(
995 #[case] price_precision: Option<u8>,
996 #[case] size_precision: Option<u8>,
997 ) {
998 let filepath = get_tardis_binance_snapshot25_path();
999 let depths =
1000 load_depth_from_snapshot25(filepath, price_precision, size_precision, None, Some(100))
1001 .unwrap();
1002
1003 assert_eq!(depths.len(), 10);
1004 assert_eq!(
1005 depths[0].instrument_id,
1006 InstrumentId::from("BTCUSDT.BINANCE")
1007 );
1008 assert_eq!(depths[0].bids.len(), 25);
1009 assert_eq!(depths[0].bids[0].price, Price::from("11657.07"));
1010 assert_eq!(depths[0].bids[0].size, Quantity::from("10.896"));
1011 assert_eq!(depths[0].bids[0].side, OrderSide::Buy.into());
1012 assert_eq!(depths[0].bids[0].order_id, 0);
1013 assert_eq!(depths[0].asks.len(), 25);
1014 assert_eq!(depths[0].asks[0].price, Price::from("11657.08"));
1015 assert_eq!(depths[0].asks[0].size, Quantity::from("1.714"));
1016 assert_eq!(depths[0].asks[0].side, OrderSide::Sell.into());
1017 assert_eq!(depths[0].asks[0].order_id, 0);
1018 assert_eq!(depths[0].bid_counts[0], 1);
1019 assert_eq!(depths[0].ask_counts[0], 1);
1020 assert_eq!(
1022 depths[0].flags,
1023 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1024 );
1025 assert_eq!(depths[0].ts_event, 1598918403696000000);
1026 assert_eq!(depths[0].ts_init, 1598918403810979000);
1027 assert_eq!(depths[0].sequence, 0);
1028 }
1029
1030 #[rstest]
1031 #[case(Some(1), Some(0))] #[case(None, None)] pub fn test_read_quotes(
1034 #[case] price_precision: Option<u8>,
1035 #[case] size_precision: Option<u8>,
1036 ) {
1037 let filepath = get_tardis_huobi_quotes_path();
1038 let quotes =
1039 load_quotes(filepath, price_precision, size_precision, None, Some(100)).unwrap();
1040
1041 assert_eq!(quotes.len(), 10);
1042 assert_eq!(
1043 quotes[0].instrument_id,
1044 InstrumentId::from("BTC-USD.HUOBI_DELIVERY")
1045 );
1046 assert_eq!(quotes[0].bid_price, Price::from("8629.2"));
1047 assert_eq!(quotes[0].bid_size, Quantity::from("806"));
1048 assert_eq!(quotes[0].ask_price, Price::from("8629.3"));
1049 assert_eq!(quotes[0].ask_size, Quantity::from("5494"));
1050 assert_eq!(quotes[0].ts_event, 1588291201099000000);
1051 assert_eq!(quotes[0].ts_init, 1588291201234268000);
1052 }
1053
1054 #[rstest]
1055 fn test_load_options_chain_filters_underlying_and_emits_quote_then_greeks() {
1056 let filepath = get_test_data_path("options_chain.csv");
1057 let data =
1058 load_options_chain(filepath, Some(vec!["btc-".to_string()]), None, None, None).unwrap();
1059
1060 assert_eq!(data.len(), 9);
1061
1062 let Data::Quote(quote) = &data[0] else {
1063 panic!("Expected first data item to be Quote");
1064 };
1065 let Data::OptionGreeks(greeks) = &data[1] else {
1066 panic!("Expected second data item to be OptionGreeks");
1067 };
1068
1069 assert_eq!(
1070 quote.instrument_id,
1071 InstrumentId::from("BTC-9JUN20-9875-P.DERIBIT")
1072 );
1073 assert_eq!(quote.bid_price, Price::from("0.0205"));
1074 assert_eq!(quote.ask_price, Price::from("0.0235"));
1075 assert_eq!(quote.bid_size, Quantity::from("15.1"));
1076 assert_eq!(quote.ask_size, Quantity::from("15.2"));
1077 assert_eq!(quote.bid_price.precision, 4);
1078 assert_eq!(quote.bid_size.precision, 1);
1079
1080 assert_eq!(greeks.instrument_id, quote.instrument_id);
1081 assert_eq!(greeks.greeks.delta, -0.61752);
1082 assert_eq!(greeks.mark_iv, Some(62.89));
1083 assert_eq!(greeks.underlying_price, Some(9756.36));
1084 }
1085
1086 #[rstest]
1087 fn test_load_options_chain_missing_bbo_emits_greeks_only_with_default_greeks() {
1088 let filepath = get_test_data_path("options_chain.csv");
1089 let data = load_options_chain(
1090 filepath,
1091 Some(vec!["BTC-10JUN20".to_string()]),
1092 None,
1093 None,
1094 None,
1095 )
1096 .unwrap();
1097
1098 assert_eq!(data.len(), 1);
1099
1100 let Data::OptionGreeks(greeks) = &data[0] else {
1101 panic!("Expected OptionGreeks, was {:?}", data[0]);
1102 };
1103
1104 assert_eq!(
1105 greeks.instrument_id,
1106 InstrumentId::from("BTC-10JUN20-10000-C.DERIBIT")
1107 );
1108 assert_eq!(greeks.open_interest, None);
1109 assert_eq!(greeks.bid_iv, None);
1110 assert_eq!(greeks.ask_iv, None);
1111 assert_eq!(greeks.greeks.delta, 0.0);
1112 assert_eq!(greeks.greeks.gamma, 0.0);
1113 assert_eq!(greeks.greeks.vega, 0.0);
1114 assert_eq!(greeks.greeks.theta, 0.0);
1115 assert_eq!(greeks.greeks.rho, 0.0);
1116 }
1117
1118 #[rstest]
1119 fn test_load_options_chain_rejects_zero_bbo_size() {
1120 let temp_file = tempfile::NamedTempFile::new().unwrap();
1121 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
1122deribit,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";
1123 fs::write(temp_file.path(), csv_data).unwrap();
1124
1125 let error = load_options_chain(temp_file.path(), None, None, None, None).unwrap_err();
1126
1127 assert_eq!(error.to_string(), "value was zero");
1128 }
1129
1130 #[rstest]
1131 fn test_load_options_chain_infers_precision_per_instrument() {
1132 let filepath = get_test_data_path("options_chain.csv");
1133 let data =
1134 load_options_chain(filepath, Some(vec!["ETH-".to_string()]), None, None, None).unwrap();
1135
1136 assert_eq!(data.len(), 3);
1137
1138 let Data::Quote(quote) = &data[0] else {
1139 panic!("Expected first data item to be Quote");
1140 };
1141
1142 assert_eq!(
1143 quote.instrument_id,
1144 InstrumentId::from("ETH-9JUN20-250-P.DERIBIT")
1145 );
1146 assert_eq!(quote.bid_price, Price::from("0.12345"));
1147 assert_eq!(quote.ask_price, Price::from("0.12456"));
1148 assert_eq!(quote.bid_size, Quantity::from("0.123456"));
1149 assert_eq!(quote.ask_size, Quantity::from("0.223456"));
1150 assert_eq!(quote.bid_price.precision, 5);
1151 assert_eq!(quote.bid_size.precision, 6);
1152
1153 assert!(matches!(data[1], Data::OptionGreeks(_)));
1154 assert!(matches!(data[2], Data::OptionGreeks(_)));
1155 }
1156
1157 #[rstest]
1158 #[case(Some(1), Some(0))] #[case(None, None)] pub fn test_read_trades(
1161 #[case] price_precision: Option<u8>,
1162 #[case] size_precision: Option<u8>,
1163 ) {
1164 let filepath = get_tardis_bitmex_trades_path();
1165 let trades =
1166 load_trades(filepath, price_precision, size_precision, None, Some(100)).unwrap();
1167
1168 assert_eq!(trades.len(), 10);
1169 assert_eq!(trades[0].instrument_id, InstrumentId::from("XBTUSD.BITMEX"));
1170 assert_eq!(trades[0].price, Price::from("8531.5"));
1171 assert_eq!(trades[0].size, Quantity::from("2152"));
1172 assert_eq!(trades[0].aggressor_side, AggressorSide::Sell);
1173 assert_eq!(
1174 trades[0].trade_id,
1175 TradeId::new("ccc3c1fa-212c-e8b0-1706-9b9c4f3d5ecf")
1176 );
1177 assert_eq!(trades[0].ts_event, 1583020803145000000);
1178 assert_eq!(trades[0].ts_init, 1583020803307160000);
1179 }
1180
1181 #[rstest]
1182 pub fn test_load_trades_derives_id_when_csv_id_empty() {
1183 let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
1186binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50000.0,1.0
1187binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50000.0,1.0
1188binance,BTCUSDT,1640995200000000,1640995200100000,,buy,50001.0,1.0";
1189
1190 let temp_file = std::env::temp_dir().join("test_load_trades_empty_id.csv");
1191 std::fs::write(&temp_file, csv_data).unwrap();
1192
1193 let trades = load_trades(&temp_file, Some(2), Some(1), None, None).unwrap();
1194 assert_eq!(trades.len(), 3);
1195
1196 assert_eq!(trades[0].trade_id, trades[1].trade_id);
1197 assert_eq!(trades[0].trade_id.as_str().len(), 16);
1198 assert_ne!(trades[0].trade_id, trades[2].trade_id);
1199
1200 std::fs::remove_file(&temp_file).ok();
1201 }
1202
1203 #[rstest]
1204 pub fn test_load_trades_with_zero_sized_trade() {
1205 let csv_data = "exchange,symbol,timestamp,local_timestamp,id,side,price,amount
1207binance,BTCUSDT,1640995200000000,1640995200100000,trade1,buy,50000.0,1.0
1208binance,BTCUSDT,1640995201000000,1640995201100000,trade2,sell,49999.5,0.0
1209binance,BTCUSDT,1640995202000000,1640995202100000,trade3,buy,50000.12,1.5
1210binance,BTCUSDT,1640995203000000,1640995203100000,trade4,sell,49999.123,3.0";
1211
1212 let temp_file = std::env::temp_dir().join("test_load_trades_zero_size.csv");
1213 std::fs::write(&temp_file, csv_data).unwrap();
1214
1215 let trades = load_trades(
1216 &temp_file,
1217 Some(4),
1218 Some(1),
1219 None,
1220 None, )
1222 .unwrap();
1223
1224 assert_eq!(trades.len(), 3);
1226
1227 assert_eq!(trades[0].size, Quantity::from("1.0"));
1229 assert_eq!(trades[1].size, Quantity::from("1.5"));
1230 assert_eq!(trades[2].size, Quantity::from("3.0"));
1231
1232 assert_eq!(trades[0].trade_id, TradeId::new("trade1"));
1234 assert_eq!(trades[1].trade_id, TradeId::new("trade3"));
1235 assert_eq!(trades[2].trade_id, TradeId::new("trade4"));
1236
1237 std::fs::remove_file(&temp_file).ok();
1238 }
1239
1240 #[rstest]
1241 pub fn test_load_trades_from_local_file() {
1242 let filepath = get_test_data_path("csv/trades_1.csv");
1243 let trades = load_trades(filepath, Some(1), Some(0), None, None).unwrap();
1244 assert_eq!(trades.len(), 2);
1245 assert_eq!(trades[0].price, Price::from("8531.5"));
1246 assert_eq!(trades[1].size, Quantity::from("1000"));
1247 }
1248
1249 #[rstest]
1250 pub fn test_load_deltas_from_local_file() {
1251 let filepath = get_test_data_path("csv/deltas_1.csv");
1252 let deltas = load_deltas(filepath, Some(1), Some(0), None, None).unwrap();
1253
1254 assert_eq!(deltas.len(), 3);
1256 assert_eq!(deltas[0].action, BookAction::Clear);
1257 assert_eq!(deltas[1].order.price, Price::from("6421.5"));
1258 assert_eq!(deltas[2].order.size, Quantity::from("10000"));
1259 }
1260
1261 #[rstest]
1262 fn test_load_deltas_groups_messages_by_local_timestamp() {
1263 let filepath = get_test_data_path("csv/deltas_message_boundaries.csv");
1264 let deltas = load_deltas(filepath, Some(1), Some(1), None, None).unwrap();
1265
1266 assert_eq!(deltas.len(), 4);
1267 assert_eq!(
1268 deltas.iter().map(|delta| delta.flags).collect::<Vec<_>>(),
1269 vec![0, RecordFlag::F_LAST as u8, 0, RecordFlag::F_LAST as u8]
1270 );
1271 assert_eq!(
1272 deltas
1273 .iter()
1274 .map(|delta| delta.ts_event)
1275 .collect::<Vec<_>>(),
1276 vec![
1277 UnixNanos::from(1_000_000),
1278 UnixNanos::from(1_000_000),
1279 UnixNanos::from(1_000_000),
1280 UnixNanos::from(1_010_000),
1281 ]
1282 );
1283 assert_eq!(
1284 deltas.iter().map(|delta| delta.ts_init).collect::<Vec<_>>(),
1285 vec![
1286 UnixNanos::from(2_000_000),
1287 UnixNanos::from(2_000_000),
1288 UnixNanos::from(2_010_000),
1289 UnixNanos::from(2_010_000),
1290 ]
1291 );
1292 assert_eq!(deltas[0].order.side, Some(OrderSide::Buy));
1293 assert_eq!(deltas[0].order.price, Price::from("100.0"));
1294 assert_eq!(deltas[0].order.size, Quantity::from("1.0"));
1295 assert_eq!(deltas[1].order.side, Some(OrderSide::Sell));
1296 assert_eq!(deltas[1].order.price, Price::from("101.0"));
1297 assert_eq!(deltas[1].order.size, Quantity::from("2.0"));
1298 assert_eq!(deltas[2].order.side, Some(OrderSide::Buy));
1299 assert_eq!(deltas[2].order.price, Price::from("99.0"));
1300 assert_eq!(deltas[2].order.size, Quantity::from("3.0"));
1301 assert_eq!(deltas[3].order.side, Some(OrderSide::Sell));
1302 assert_eq!(deltas[3].order.price, Price::from("102.0"));
1303 assert_eq!(deltas[3].order.size, Quantity::from("4.0"));
1304 }
1305
1306 #[rstest]
1307 fn test_load_funding_rates_okex_xperp() {
1308 let filepath = get_test_data_path("csv/okex_futures_xperp_derivative_ticker.csv");
1309 let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1310
1311 let instrument_id = InstrumentId::from("BTC-USD_UM_XPERP-310404.OKEX");
1312
1313 assert_eq!(funding_rates.len(), 8);
1314 assert!(
1315 funding_rates
1316 .iter()
1317 .all(|f| f.instrument_id == instrument_id)
1318 );
1319
1320 assert!(
1323 funding_rates
1324 .iter()
1325 .all(|f| f.next_funding_ns.is_some() && f.interval.is_none())
1326 );
1327
1328 let first = &funding_rates[0];
1329 let rolled = &funding_rates[3];
1330
1331 assert_eq!(first.rate, dec!(-0.0003972900658902));
1332 assert_eq!(
1333 first.next_funding_ns,
1334 Some(UnixNanos::from(1_786_320_000_000_000_000))
1335 );
1336 assert_eq!(first.ts_event, UnixNanos::from(1_786_320_006_952_000_000));
1337 assert_eq!(first.ts_init, UnixNanos::from(1_786_320_006_971_532_000));
1338
1339 assert_eq!(rolled.rate, dec!(-0.0003962534258591));
1340 assert_eq!(
1341 rolled.next_funding_ns,
1342 Some(UnixNanos::from(1_786_348_800_000_000_000))
1343 );
1344 assert_eq!(rolled.ts_event, UnixNanos::from(1_786_320_007_369_000_000));
1345 assert_eq!(rolled.ts_init, UnixNanos::from(1_786_320_007_402_423_000));
1346 }
1347
1348 #[rstest]
1349 fn test_load_funding_rates_without_funding_timestamp() {
1350 let filepath = get_test_data_path("csv/deribit_derivative_ticker.csv");
1351 let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1352
1353 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1354
1355 assert_eq!(funding_rates.len(), 3);
1356 assert!(
1357 funding_rates
1358 .iter()
1359 .all(|f| f.instrument_id == instrument_id)
1360 );
1361
1362 assert!(
1364 funding_rates
1365 .iter()
1366 .all(|f| f.next_funding_ns.is_none() && f.interval.is_none())
1367 );
1368
1369 let first = &funding_rates[0];
1370 let changed = &funding_rates[2];
1371
1372 assert_eq!(first.rate, dec!(0.00000459));
1373 assert_eq!(first.ts_event, UnixNanos::from(1_786_320_665_523_000_000));
1374 assert_eq!(first.ts_init, UnixNanos::from(1_786_320_665_533_324_000));
1375
1376 assert_eq!(changed.rate, dec!(0.00000452));
1377 assert_eq!(changed.ts_event, UnixNanos::from(1_786_320_665_645_000_000));
1378 assert_eq!(changed.ts_init, UnixNanos::from(1_786_320_665_661_106_000));
1379 }
1380
1381 #[rstest]
1382 fn test_load_funding_rates_okex_usdc_across_index_migration() {
1383 let filepath = get_test_data_path("csv/okex_swap_usdc_index_migration.csv");
1384 let funding_rates = load_funding_rates(filepath, None, None).unwrap();
1385
1386 let instrument_id = InstrumentId::from("BTC-USDC-SWAP.OKEX");
1387
1388 assert_eq!(funding_rates.len(), 6);
1389
1390 assert!(
1394 funding_rates
1395 .iter()
1396 .all(|f| f.instrument_id == instrument_id)
1397 );
1398
1399 let pre = &funding_rates[0];
1400 let post = &funding_rates[3];
1401
1402 assert_eq!(pre.rate, dec!(0.0001035718476117));
1403 assert_eq!(
1404 pre.next_funding_ns,
1405 Some(UnixNanos::from(1_680_336_000_000_000_000))
1406 );
1407 assert_eq!(pre.ts_event, UnixNanos::from(1_680_309_048_427_000_000));
1408 assert_eq!(pre.ts_init, UnixNanos::from(1_680_309_048_450_728_000));
1409
1410 assert_eq!(post.rate, dec!(-0.000055804472025));
1411 assert_eq!(
1412 post.next_funding_ns,
1413 Some(UnixNanos::from(1_682_928_000_000_000_000))
1414 );
1415 assert_eq!(post.ts_event, UnixNanos::from(1_682_900_658_676_000_000));
1416 assert_eq!(post.ts_init, UnixNanos::from(1_682_900_658_698_855_000));
1417 }
1418
1419 #[rstest]
1420 fn test_load_depth_from_snapshot5_comprehensive() {
1421 let filepath = get_tardis_binance_snapshot5_path();
1422 let depths = load_depth_from_snapshot5(&filepath, None, None, None, Some(100)).unwrap();
1423
1424 assert_eq!(depths.len(), 10);
1425
1426 let first = &depths[0];
1427 assert_eq!(first.instrument_id.to_string(), "BTCUSDT.BINANCE");
1428 assert_eq!(first.bids.len(), 5);
1429 assert_eq!(first.asks.len(), 5);
1430
1431 assert_eq!(first.bids[0].price, Price::from("11657.07"));
1433 assert_eq!(first.bids[0].size, Quantity::from("10.896"));
1434 assert_eq!(first.bids[0].side, OrderSide::Buy.into());
1435
1436 assert_eq!(first.bids[1].price, Price::from("11656.97"));
1437 assert_eq!(first.bids[1].size, Quantity::from("0.2"));
1438 assert_eq!(first.bids[1].side, OrderSide::Buy.into());
1439
1440 assert_eq!(first.bids[2].price, Price::from("11655.78"));
1441 assert_eq!(first.bids[2].size, Quantity::from("0.2"));
1442 assert_eq!(first.bids[2].side, OrderSide::Buy.into());
1443
1444 assert_eq!(first.bids[3].price, Price::from("11655.77"));
1445 assert_eq!(first.bids[3].size, Quantity::from("0.98"));
1446 assert_eq!(first.bids[3].side, OrderSide::Buy.into());
1447
1448 assert_eq!(first.bids[4].price, Price::from("11655.68"));
1449 assert_eq!(first.bids[4].size, Quantity::from("0.111"));
1450 assert_eq!(first.bids[4].side, OrderSide::Buy.into());
1451
1452 assert_eq!(first.asks[0].price, Price::from("11657.08"));
1454 assert_eq!(first.asks[0].size, Quantity::from("1.714"));
1455 assert_eq!(first.asks[0].side, OrderSide::Sell.into());
1456
1457 assert_eq!(first.asks[1].price, Price::from("11657.54"));
1458 assert_eq!(first.asks[1].size, Quantity::from("5.4"));
1459 assert_eq!(first.asks[1].side, OrderSide::Sell.into());
1460
1461 assert_eq!(first.asks[2].price, Price::from("11657.56"));
1462 assert_eq!(first.asks[2].size, Quantity::from("0.238"));
1463 assert_eq!(first.asks[2].side, OrderSide::Sell.into());
1464
1465 assert_eq!(first.asks[3].price, Price::from("11657.61"));
1466 assert_eq!(first.asks[3].size, Quantity::from("0.077"));
1467 assert_eq!(first.asks[3].side, OrderSide::Sell.into());
1468
1469 assert_eq!(first.asks[4].price, Price::from("11657.92"));
1470 assert_eq!(first.asks[4].size, Quantity::from("0.918"));
1471 assert_eq!(first.asks[4].side, OrderSide::Sell.into());
1472
1473 for i in 1..5 {
1475 assert!(
1476 first.bids[i].price < first.bids[i - 1].price,
1477 "Bid price at level {} should be less than level {}",
1478 i,
1479 i - 1
1480 );
1481 }
1482
1483 for i in 1..5 {
1485 assert!(
1486 first.asks[i].price > first.asks[i - 1].price,
1487 "Ask price at level {} should be greater than level {}",
1488 i,
1489 i - 1
1490 );
1491 }
1492
1493 assert!(
1495 first.asks[0].price > first.bids[0].price,
1496 "Best ask should be greater than best bid"
1497 );
1498
1499 assert_eq!(first.bid_counts.as_slice(), &[1; 5]);
1500 assert_eq!(first.ask_counts.as_slice(), &[1; 5]);
1501 for order in first.bids.iter().chain(&first.asks) {
1502 assert_eq!(order.order_id, 0);
1503 }
1504
1505 assert_eq!(
1507 first.flags,
1508 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1509 );
1510 assert_eq!(first.ts_event.as_u64(), 1598918403696000000);
1511 assert_eq!(first.ts_init.as_u64(), 1598918403810979000);
1512 assert_eq!(first.sequence, 0);
1513 }
1514
1515 #[rstest]
1516 fn test_load_depth_from_snapshot25_comprehensive() {
1517 let filepath = get_tardis_binance_snapshot25_path();
1518 let depths = load_depth_from_snapshot25(&filepath, None, None, None, Some(100)).unwrap();
1519
1520 assert_eq!(depths.len(), 10);
1521
1522 let first = &depths[0];
1523 assert_eq!(first.instrument_id.to_string(), "BTCUSDT.BINANCE");
1524 assert_eq!(first.bids.len(), 25);
1525 assert_eq!(first.asks.len(), 25);
1526
1527 let expected_bids = vec![
1529 ("11657.07", "10.896"),
1530 ("11656.97", "0.2"),
1531 ("11655.78", "0.2"),
1532 ("11655.77", "0.98"),
1533 ("11655.68", "0.111"),
1534 ("11655.66", "0.077"),
1535 ("11655.57", "0.34"),
1536 ("11655.48", "0.4"),
1537 ("11655.26", "1.185"),
1538 ("11654.86", "0.195"),
1539 ("11654.85", "0.275"),
1540 ("11654.7", "0.175"),
1541 ("11654.69", "0.194"),
1542 ("11654.67", "1"),
1543 ("11654.65", "0.05"),
1544 ("11654.58", "0.05"),
1545 ("11654.41", "0.11"),
1546 ("11654.28", "0.618"),
1547 ("11653.84", "0.135"),
1548 ("11653.4", "0.17"),
1549 ("11653.39", "1.008"),
1550 ("11653.35", "4"),
1551 ("11653.34", "2"),
1552 ("11653.32", "0.5"),
1553 ("11653.25", "1.003"),
1554 ];
1555
1556 for (i, (price, size)) in expected_bids.iter().enumerate() {
1557 assert_eq!(first.bids[i].price, Price::from(*price));
1558 assert_eq!(first.bids[i].size, Quantity::from(*size));
1559 assert_eq!(first.bids[i].side, OrderSide::Buy.into());
1560 }
1561
1562 let expected_asks = vec![
1564 ("11657.08", "1.714"),
1565 ("11657.54", "5.4"),
1566 ("11657.56", "0.238"),
1567 ("11657.61", "0.077"),
1568 ("11657.92", "0.918"),
1569 ("11658.09", "1.015"),
1570 ("11658.12", "0.665"),
1571 ("11658.19", "0.583"),
1572 ("11658.28", "0.255"),
1573 ("11658.29", "0.656"),
1574 ("11658.64", "1.463"),
1575 ("11658.71", "0.155"),
1576 ("11658.75", "0.155"),
1577 ("11658.88", "0.625"),
1578 ("11658.94", "0.155"),
1579 ("11658.98", "1.005"),
1580 ("11658.99", "0.155"),
1581 ("11659", "1.922"),
1582 ("11659.02", "0.001"),
1583 ("11659.13", "0.11"),
1584 ("11659.17", "0.144"),
1585 ("11659.18", "0.665"),
1586 ("11659.22", "0.22"),
1587 ("11659.28", "0.06"),
1588 ("11659.34", "0.618"),
1589 ];
1590
1591 for (i, (price, size)) in expected_asks.iter().enumerate() {
1592 assert_eq!(first.asks[i].price, Price::from(*price));
1593 assert_eq!(first.asks[i].size, Quantity::from(*size));
1594 assert_eq!(first.asks[i].side, OrderSide::Sell.into());
1595 }
1596
1597 for i in 1..25 {
1599 assert!(
1600 first.bids[i].price < first.bids[i - 1].price,
1601 "Bid price at level {} ({}) should be less than level {} ({})",
1602 i,
1603 first.bids[i].price,
1604 i - 1,
1605 first.bids[i - 1].price
1606 );
1607 }
1608
1609 for i in 1..25 {
1611 assert!(
1612 first.asks[i].price > first.asks[i - 1].price,
1613 "Ask price at level {} ({}) should be greater than level {} ({})",
1614 i,
1615 first.asks[i].price,
1616 i - 1,
1617 first.asks[i - 1].price
1618 );
1619 }
1620
1621 assert!(
1623 first.asks[0].price > first.bids[0].price,
1624 "Best ask ({}) should be greater than best bid ({})",
1625 first.asks[0].price,
1626 first.bids[0].price
1627 );
1628
1629 for i in 0..25 {
1631 assert_eq!(first.bid_counts[i], 1);
1632 assert_eq!(first.ask_counts[i], 1);
1633 }
1634
1635 assert_eq!(
1637 first.flags,
1638 RecordFlag::F_SNAPSHOT as u8 | RecordFlag::F_LAST as u8
1639 );
1640 assert_eq!(first.ts_event.as_u64(), 1598918403696000000);
1641 assert_eq!(first.ts_init.as_u64(), 1598918403810979000);
1642 assert_eq!(first.sequence, 0);
1643 }
1644
1645 fn write_precision_rewrite_csv(path: &std::path::Path, levels: usize) {
1649 let mut headers = vec![
1650 "exchange".to_string(),
1651 "symbol".to_string(),
1652 "timestamp".to_string(),
1653 "local_timestamp".to_string(),
1654 ];
1655
1656 for i in 0..levels {
1657 headers.extend([
1658 format!("asks[{i}].price"),
1659 format!("asks[{i}].amount"),
1660 format!("bids[{i}].price"),
1661 format!("bids[{i}].amount"),
1662 ]);
1663 }
1664
1665 let row = |timestamp: &str, best_bid: &str| -> String {
1666 let mut fields = vec![
1667 "binance-futures".to_string(),
1668 "BTCUSDT".to_string(),
1669 timestamp.to_string(),
1670 timestamp.to_string(),
1671 ];
1672
1673 for i in 0..levels {
1674 let bid = if i == 0 {
1675 best_bid.to_string()
1676 } else {
1677 format!("{}", 49_990 - i)
1678 };
1679 fields.extend([
1680 format!("{}", 50_001 + i),
1681 "1.5".to_string(),
1682 bid,
1683 "1.5".to_string(),
1684 ]);
1685 }
1686 fields.join(",")
1687 };
1688
1689 let csv_data = format!(
1690 "{}\n{}\n{}",
1691 headers.join(","),
1692 row("1640995200000000", "49999"),
1693 row("1640995201000000", "49998.12"),
1694 );
1695 std::fs::write(path, csv_data).unwrap();
1696 }
1697
1698 #[rstest]
1699 fn test_load_depth_from_snapshot25_precision_rewrite_updates_all_levels() {
1700 let temp_file = std::env::temp_dir().join("test_depth_snapshot25_precision_rewrite.csv");
1701 write_precision_rewrite_csv(&temp_file, 25);
1702
1703 let depths = load_depth_from_snapshot25(&temp_file, None, None, None, None).unwrap();
1704 assert_eq!(depths.len(), 2);
1705
1706 for (r, depth) in depths.iter().enumerate() {
1709 assert_eq!(depth.bids.len(), 25, "row {r}");
1710 assert_eq!(depth.asks.len(), 25, "row {r}");
1711 for (i, order) in depth.bids.iter().chain(depth.asks.iter()).enumerate() {
1712 assert_eq!(order.price.precision, 2, "row {r} level {i}");
1713 assert_eq!(order.size.precision, 1, "row {r} level {i}");
1714 }
1715 }
1716 assert_eq!(depths[0].bids[0].price, Price::new(49999.0, 2));
1718 assert_eq!(depths[0].bids[24].price, Price::new(49966.0, 2));
1719 assert_eq!(depths[1].bids[0].price, Price::new(49998.12, 2));
1720
1721 std::fs::remove_file(&temp_file).ok();
1722 }
1723
1724 #[rstest]
1725 fn test_load_depth_from_snapshot5_precision_rewrite_updates_all_levels() {
1726 let temp_file = std::env::temp_dir().join("test_depth_snapshot5_precision_rewrite.csv");
1727 write_precision_rewrite_csv(&temp_file, 5);
1728
1729 let depths = load_depth_from_snapshot5(&temp_file, None, None, None, None).unwrap();
1730 assert_eq!(depths.len(), 2);
1731
1732 for (r, depth) in depths.iter().enumerate() {
1734 assert_eq!(depth.bids.len(), 5, "row {r}");
1735 assert_eq!(depth.asks.len(), 5, "row {r}");
1736 for (i, order) in depth.bids.iter().chain(depth.asks.iter()).enumerate() {
1737 assert_eq!(order.price.precision, 2, "row {r} level {i}");
1738 assert_eq!(order.size.precision, 1, "row {r} level {i}");
1739 }
1740 }
1741 assert_eq!(depths[0].bids[0].price, Price::new(49999.0, 2));
1742 assert_eq!(depths[1].bids[0].price, Price::new(49998.12, 2));
1743
1744 std::fs::remove_file(&temp_file).ok();
1745 }
1746
1747 #[rstest]
1748 fn test_snapshot_csv_field_order_interleaved() {
1749 let csv_data = "exchange,symbol,timestamp,local_timestamp,\
1753asks[0].price,asks[0].amount,bids[0].price,bids[0].amount,\
1754asks[1].price,asks[1].amount,bids[1].price,bids[1].amount,\
1755asks[2].price,asks[2].amount,bids[2].price,bids[2].amount,\
1756asks[3].price,asks[3].amount,bids[3].price,bids[3].amount,\
1757asks[4].price,asks[4].amount,bids[4].price,bids[4].amount
1758binance-futures,BTCUSDT,1000000,2000000,\
1759100.5,1.0,100.4,2.0,\
1760100.6,1.1,100.3,2.1,\
1761100.7,1.2,100.2,2.2,\
1762100.8,1.3,100.1,2.3,\
1763100.9,1.4,100.0,2.4";
1764
1765 let temp_file = std::env::temp_dir().join("test_interleaved_snapshot5.csv");
1766 std::fs::write(&temp_file, csv_data).unwrap();
1767
1768 let depths = load_depth_from_snapshot5(&temp_file, None, None, None, Some(1)).unwrap();
1769 assert_eq!(depths.len(), 1);
1770
1771 let depth = &depths[0];
1772
1773 assert_eq!(depth.bids[0].price, Price::from("100.4"));
1775 assert_eq!(depth.bids[1].price, Price::from("100.3"));
1776 assert_eq!(depth.bids[2].price, Price::from("100.2"));
1777 assert_eq!(depth.bids[3].price, Price::from("100.1"));
1778 assert_eq!(depth.bids[4].price, Price::from("100.0"));
1779
1780 assert_eq!(depth.asks[0].price, Price::from("100.5"));
1782 assert_eq!(depth.asks[1].price, Price::from("100.6"));
1783 assert_eq!(depth.asks[2].price, Price::from("100.7"));
1784 assert_eq!(depth.asks[3].price, Price::from("100.8"));
1785 assert_eq!(depth.asks[4].price, Price::from("100.9"));
1786
1787 assert_eq!(depth.bids[0].size, Quantity::from("2.0"));
1789 assert_eq!(depth.asks[0].size, Quantity::from("1.0"));
1790
1791 std::fs::remove_file(temp_file).unwrap();
1792 }
1793
1794 #[rstest]
1795 fn test_load_deltas_limit_includes_clear_deltas() {
1796 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1799binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1800binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0
1801binance-futures,BTCUSDT,1640995201000000,1640995201100000,false,bid,49999.0,0.5
1802binance-futures,BTCUSDT,1640995202000000,1640995202100000,false,ask,50002.0,1.5
1803binance-futures,BTCUSDT,1640995203000000,1640995203100000,false,bid,49998.0,0.5
1804binance-futures,BTCUSDT,1640995204000000,1640995204100000,false,ask,50003.0,2.0
1805binance-futures,BTCUSDT,1640995205000000,1640995205100000,false,bid,49997.0,0.5";
1806
1807 let temp_file = std::env::temp_dir().join("test_load_deltas_limit.csv");
1808 std::fs::write(&temp_file, csv_data).unwrap();
1809
1810 let deltas = load_deltas(&temp_file, Some(1), Some(1), None, Some(5)).unwrap();
1812
1813 assert_eq!(deltas.len(), 5);
1815 assert_eq!(deltas[0].action, BookAction::Clear);
1816 assert_eq!(deltas[1].action, BookAction::Add);
1817 assert_eq!(deltas[2].action, BookAction::Add);
1818 assert_eq!(deltas[3].action, BookAction::Update);
1819 assert_eq!(deltas[4].action, BookAction::Update);
1820
1821 assert_eq!(deltas[3].order.price, parse_price(49999.0, 1));
1823
1824 std::fs::remove_file(&temp_file).ok();
1825 }
1826
1827 #[rstest]
1828 fn test_load_deltas_limit_stops_at_clear() {
1829 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1831binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1832binance-futures,BTCUSDT,1640995200000000,1640995200100000,true,ask,50001.0,2.0";
1833
1834 let temp_file = std::env::temp_dir().join("test_load_deltas_limit_stops_at_clear.csv");
1835 std::fs::write(&temp_file, csv_data).unwrap();
1836
1837 let deltas = load_deltas(&temp_file, Some(1), Some(1), None, Some(1)).unwrap();
1839
1840 assert_eq!(deltas.len(), 1);
1841 assert_eq!(deltas[0].action, BookAction::Clear);
1842
1843 std::fs::remove_file(&temp_file).ok();
1844 }
1845
1846 #[rstest]
1847 fn test_load_deltas_with_consecutive_snapshots_inserts_clear() {
1848 let csv_data = "exchange,symbol,timestamp,local_timestamp,is_snapshot,side,price,amount
1849hyperliquid,BTC,1640995200000000,1640995200100000,true,bid,50000.0,1.0
1850hyperliquid,BTC,1640995200000001,1640995200100000,true,ask,50001.0,2.0
1851hyperliquid,BTC,1640995201000000,1640995201100000,true,bid,49990.0,3.0
1852hyperliquid,BTC,1640995201000001,1640995201100000,true,ask,49991.0,4.0";
1853
1854 let temp_file = std::env::temp_dir().join("test_load_deltas_consecutive_snapshots.csv");
1855 std::fs::write(&temp_file, csv_data).unwrap();
1856
1857 let deltas = load_deltas(&temp_file, Some(1), Some(1), None, None).unwrap();
1858 let clear_count = deltas
1859 .iter()
1860 .filter(|d| d.action == BookAction::Clear)
1861 .count();
1862
1863 assert_eq!(clear_count, 2);
1864 assert_eq!(deltas[0].action, BookAction::Clear);
1865 assert_eq!(deltas[3].action, BookAction::Clear);
1866 assert_eq!(
1867 deltas[2].flags & RecordFlag::F_LAST as u8,
1868 RecordFlag::F_LAST as u8
1869 );
1870 assert_eq!(deltas[3].flags & RecordFlag::F_LAST as u8, 0);
1871 assert_eq!(
1872 deltas
1873 .iter()
1874 .map(|delta| (delta.action, delta.flags, delta.ts_event, delta.ts_init))
1875 .collect::<Vec<_>>(),
1876 vec![
1877 (
1878 BookAction::Clear,
1879 RecordFlag::F_SNAPSHOT as u8,
1880 UnixNanos::from(1_640_995_200_000_000_000),
1881 UnixNanos::from(1_640_995_200_100_000_000),
1882 ),
1883 (
1884 BookAction::Add,
1885 0,
1886 UnixNanos::from(1_640_995_200_000_000_000),
1887 UnixNanos::from(1_640_995_200_100_000_000),
1888 ),
1889 (
1890 BookAction::Add,
1891 RecordFlag::F_LAST as u8,
1892 UnixNanos::from(1_640_995_200_000_001_000),
1893 UnixNanos::from(1_640_995_200_100_000_000),
1894 ),
1895 (
1896 BookAction::Clear,
1897 RecordFlag::F_SNAPSHOT as u8,
1898 UnixNanos::from(1_640_995_201_000_000_000),
1899 UnixNanos::from(1_640_995_201_100_000_000),
1900 ),
1901 (
1902 BookAction::Add,
1903 0,
1904 UnixNanos::from(1_640_995_201_000_000_000),
1905 UnixNanos::from(1_640_995_201_100_000_000),
1906 ),
1907 (
1908 BookAction::Add,
1909 RecordFlag::F_LAST as u8,
1910 UnixNanos::from(1_640_995_201_000_001_000),
1911 UnixNanos::from(1_640_995_201_100_000_000),
1912 ),
1913 ]
1914 );
1915
1916 std::fs::remove_file(&temp_file).ok();
1917 }
1918
1919 #[rstest]
1920 fn test_load_deltas_limit_with_mid_day_snapshot() {
1921 let filepath = get_test_data_path("csv/deltas_with_snapshot.csv");
1924 let deltas = load_deltas(filepath, Some(1), Some(1), None, Some(5)).unwrap();
1925
1926 assert_eq!(deltas.len(), 5);
1929 assert_eq!(deltas[0].action, BookAction::Clear);
1930 }
1931
1932 #[rstest]
1935 #[ignore = "one-time dataset curation, not for routine CI"]
1936 fn test_curate_deribit_deltas() {
1937 let csv_path = get_test_data_root()
1938 .join("large")
1939 .join("tardis_deribit_incremental_book_L2_2020-04-01_BTC-PERPETUAL.csv.gz");
1940
1941 let instrument_id = InstrumentId::from("BTC-PERPETUAL.DERIBIT");
1942 let parquet_path = "/tmp/tardis_BTC-PERPETUAL.DERIBIT_2020-04-01_deltas.parquet";
1943
1944 println!("Loading deltas from {}", csv_path.display());
1945 let deltas = load_deltas(&csv_path, None, None, Some(instrument_id), None).unwrap();
1946 let count = deltas.len();
1947 println!("Loaded {count} deltas");
1948
1949 let sample = deltas
1950 .iter()
1951 .find(|d| d.order.price.precision > 0)
1952 .expect("Should have at least one non-CLEAR delta");
1953 let price_precision = sample.order.price.precision;
1954 let size_precision = sample.order.size.precision;
1955 println!("Precision: price={price_precision}, size={size_precision}");
1956
1957 let metadata =
1959 OrderBookDelta::get_metadata(&instrument_id, price_precision, size_precision);
1960 let schema = OrderBookDelta::get_schema(Some(metadata.clone()));
1961
1962 println!("Writing Parquet to {parquet_path}");
1963 let file = File::create(parquet_path).unwrap();
1964 let zstd_level = parquet::basic::ZstdLevel::try_new(3).unwrap();
1965 let props = WriterProperties::builder()
1966 .set_compression(parquet::basic::Compression::ZSTD(zstd_level))
1967 .set_max_row_group_row_count(Some(1_000_000))
1968 .build();
1969 let mut writer = ArrowWriter::try_new(file, Arc::new(schema), Some(props)).unwrap();
1970
1971 let chunk_size = 1_000_000;
1972 for (i, chunk) in deltas.chunks(chunk_size).enumerate() {
1973 println!(" Encoding chunk {} ({} records)...", i + 1, chunk.len());
1974 let batch = OrderBookDelta::encode_batch(&metadata, chunk).unwrap();
1975 writer.write(&batch).unwrap();
1976 }
1977 writer.close().unwrap();
1978
1979 let file_size = fs::metadata(parquet_path).unwrap().len();
1980 println!("\n=== CURATION COMPLETE ===");
1981 println!("Records: {count}");
1982 println!("Price precision: {price_precision}");
1983 println!("Size precision: {size_precision}");
1984 println!(
1985 "File size: {} bytes ({:.1} MB)",
1986 file_size,
1987 file_size as f64 / 1_048_576.0
1988 );
1989 println!("Output: {parquet_path}");
1990 println!("\nNext steps:");
1991 println!(" sha256sum {parquet_path}");
1992 }
1993}