1use std::{path::PathBuf, time::Duration};
17
18use ahash::AHashMap;
19use anyhow::Context;
20use csv::StringRecord;
21use nautilus_core::UnixNanos;
22use nautilus_model::{
23 data::{Data, HasTsInit},
24 enums::OptionKind,
25 identifiers::{InstrumentId, Symbol},
26 instruments::{CryptoOption, InstrumentAny},
27 types::{Currency, Price, Quantity, fixed::FIXED_PRECISION},
28};
29use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
30use rust_decimal::Decimal;
31
32use crate::{
33 common::{
34 enums::{TardisExchange, TardisOptionType},
35 parse::{parse_instrument_id, parse_option_kind, parse_timestamp},
36 },
37 csv::{
38 create_csv_reader, infer_precision, load::OptionsChainPrecision, matches_underlying_filter,
39 normalize_underlying_filters, parse_options_chain_record,
40 parse_options_chain_record_as_quote, record::TardisOptionsChainRecord,
41 },
42};
43
44const DATA_FLUSH_ROWS: usize = 100_000;
45
46#[derive(Debug, Clone, bon::Builder)]
48pub struct TardisOptionsChainCSVConverterConfig {
49 pub filepaths: Vec<PathBuf>,
51 pub catalog_path: PathBuf,
53 pub underlyings: Option<Vec<String>>,
55 pub snapshot_interval: Option<Duration>,
57 #[builder(default = true)]
59 pub extract_bbo_as_quotes: bool,
60 #[builder(default = true)]
62 pub write_instruments: bool,
63 pub price_precision: Option<u8>,
65 pub size_precision: Option<u8>,
67}
68
69pub fn convert_options_chain_csv(
77 config: &TardisOptionsChainCSVConverterConfig,
78) -> anyhow::Result<()> {
79 let underlyings = normalize_underlying_filters(config.underlyings.clone());
80 let catalog = ParquetDataCatalog::from_uri(
81 &config.catalog_path.to_string_lossy(),
82 None,
83 None,
84 None,
85 None,
86 )?;
87 let mut precision_by_instrument: AHashMap<InstrumentId, OptionsChainPrecision> =
88 AHashMap::new();
89 let mut instrument_states: AHashMap<InstrumentId, InstrumentBuildState> = AHashMap::new();
90 let mut data_buffers: AHashMap<InstrumentId, DataBuffer> = AHashMap::new();
91 let mut pending_records: AHashMap<(InstrumentId, u64), TardisOptionsChainRecord> =
92 AHashMap::new();
93 let mut current_bucket = None;
94
95 for filepath in &config.filepaths {
96 let mut reader = create_csv_reader(filepath)
97 .with_context(|| format!("failed to open CSV file {}", filepath.display()))?;
98 let mut csv_record = StringRecord::new();
99
100 while reader
101 .read_record(&mut csv_record)
102 .with_context(|| format!("failed to read CSV file {}", filepath.display()))?
103 {
104 if let Some(underlyings) = underlyings.as_deref() {
105 let Some(symbol) = csv_record.get(1) else {
106 continue;
107 };
108 let symbol = symbol.to_uppercase();
109 if !matches_underlying_filter(&symbol, Some(underlyings)) {
110 continue;
111 }
112 }
113
114 let record: TardisOptionsChainRecord = csv_record
115 .deserialize(None)
116 .with_context(|| format!("failed to parse CSV file {}", filepath.display()))?;
117 let instrument_id = parse_instrument_id(&record.exchange, record.symbol);
118 precision_by_instrument
119 .entry(instrument_id)
120 .or_insert_with(|| {
121 OptionsChainPrecision::new(config.price_precision, config.size_precision)
122 })
123 .update(&record, config.price_precision, config.size_precision);
124 instrument_states
125 .entry(instrument_id)
126 .and_modify(|state| state.update_activation(record.local_timestamp))
127 .or_insert_with(|| InstrumentBuildState::new(record.clone()));
128
129 if let Some(interval) = config.snapshot_interval {
130 let interval_us = u64::try_from(interval.as_micros())
131 .context("snapshot interval exceeds u64 microseconds")?;
132 anyhow::ensure!(interval_us > 0, "snapshot interval must be positive");
133 let bucket = record.local_timestamp / interval_us;
134
135 if let Some(current_bucket) = current_bucket {
136 anyhow::ensure!(
137 bucket >= current_bucket,
138 "options_chain CSV rows must be ordered by local_timestamp when thinning"
139 );
140 }
141
142 if current_bucket.is_none_or(|current| bucket > current) {
143 flush_pending_records_before(
144 &catalog,
145 &mut pending_records,
146 &mut data_buffers,
147 &precision_by_instrument,
148 bucket,
149 config.extract_bbo_as_quotes,
150 )?;
151 current_bucket = Some(bucket);
152 }
153 pending_records
154 .entry((instrument_id, bucket))
155 .and_modify(|pending| {
156 if record.local_timestamp >= pending.local_timestamp {
157 *pending = record.clone();
158 }
159 })
160 .or_insert(record);
161 } else {
162 flush_data_buffer_if_ready(
163 &catalog,
164 &mut data_buffers,
165 instrument_id,
166 parse_timestamp(record.local_timestamp),
167 )?;
168 let data = options_chain_record_to_data(
169 &record,
170 &precision_by_instrument,
171 config.extract_bbo_as_quotes,
172 )?;
173 data_buffers
174 .entry(instrument_id)
175 .or_default()
176 .extend(data, parse_timestamp(record.local_timestamp));
177 }
178 }
179
180 if config.snapshot_interval.is_some() {
181 flush_pending_records_before(
182 &catalog,
183 &mut pending_records,
184 &mut data_buffers,
185 &precision_by_instrument,
186 u64::MAX,
187 config.extract_bbo_as_quotes,
188 )?;
189 flush_data_buffers(&catalog, &mut data_buffers)?;
190 current_bucket = None;
191 }
192 }
193
194 flush_data_buffers(&catalog, &mut data_buffers)?;
195
196 if config.write_instruments {
197 let instruments = build_instruments(instrument_states, &precision_by_instrument)?;
198 catalog.write_instruments(instruments)?;
199 }
200
201 Ok(())
202}
203
204#[derive(Debug, Clone)]
205struct InstrumentBuildState {
206 record: TardisOptionsChainRecord,
207 activation: UnixNanos,
208}
209
210impl InstrumentBuildState {
211 fn new(record: TardisOptionsChainRecord) -> Self {
212 Self {
213 activation: parse_timestamp(record.local_timestamp),
214 record,
215 }
216 }
217
218 fn update_activation(&mut self, local_timestamp: u64) {
219 self.activation = self.activation.min(parse_timestamp(local_timestamp));
220 }
221}
222
223#[derive(Debug, Default)]
224struct DataBuffer {
225 data: Vec<Data>,
226 last_ts_init: Option<UnixNanos>,
227}
228
229impl DataBuffer {
230 fn extend(&mut self, mut data: Vec<Data>, ts_init: UnixNanos) {
231 self.data.append(&mut data);
232 self.last_ts_init = Some(ts_init);
233 }
234}
235
236fn flush_data_buffer_if_ready(
237 catalog: &ParquetDataCatalog,
238 data_buffers: &mut AHashMap<InstrumentId, DataBuffer>,
239 instrument_id: InstrumentId,
240 ts_init: UnixNanos,
241) -> anyhow::Result<()> {
242 let Some(buffer) = data_buffers.get_mut(&instrument_id) else {
243 return Ok(());
244 };
245
246 if buffer.data.len() >= DATA_FLUSH_ROWS
247 && buffer
248 .last_ts_init
249 .is_some_and(|last_ts_init| last_ts_init < ts_init)
250 {
251 write_data_buffer(catalog, &mut buffer.data)?;
252 }
253
254 Ok(())
255}
256
257fn flush_data_buffers(
258 catalog: &ParquetDataCatalog,
259 data_buffers: &mut AHashMap<InstrumentId, DataBuffer>,
260) -> anyhow::Result<()> {
261 for buffer in data_buffers.values_mut() {
262 write_data_buffer(catalog, &mut buffer.data)?;
263 }
264 data_buffers.clear();
265 Ok(())
266}
267
268fn flush_pending_records_before(
269 catalog: &ParquetDataCatalog,
270 pending_records: &mut AHashMap<(InstrumentId, u64), TardisOptionsChainRecord>,
271 data_buffers: &mut AHashMap<InstrumentId, DataBuffer>,
272 precision_by_instrument: &AHashMap<InstrumentId, OptionsChainPrecision>,
273 next_bucket: u64,
274 extract_bbo_as_quotes: bool,
275) -> anyhow::Result<()> {
276 let mut ready = Vec::new();
277 pending_records.retain(|(_, bucket), record| {
278 if *bucket < next_bucket {
279 ready.push(record.clone());
280 false
281 } else {
282 true
283 }
284 });
285 ready.sort_by_key(|record| (record.local_timestamp, record.symbol));
286
287 for record in ready {
288 let instrument_id = parse_instrument_id(&record.exchange, record.symbol);
289 let ts_init = parse_timestamp(record.local_timestamp);
290 flush_data_buffer_if_ready(catalog, data_buffers, instrument_id, ts_init)?;
291 let data =
292 options_chain_record_to_data(&record, precision_by_instrument, extract_bbo_as_quotes)?;
293 data_buffers
294 .entry(instrument_id)
295 .or_default()
296 .extend(data, ts_init);
297 }
298 Ok(())
299}
300
301fn options_chain_record_to_data(
302 record: &TardisOptionsChainRecord,
303 precision_by_instrument: &AHashMap<InstrumentId, OptionsChainPrecision>,
304 extract_bbo_as_quotes: bool,
305) -> anyhow::Result<Vec<Data>> {
306 let instrument_id = parse_instrument_id(&record.exchange, record.symbol);
307 let precision = precision_by_instrument
308 .get(&instrument_id)
309 .copied()
310 .unwrap_or_else(|| OptionsChainPrecision::new(None, None));
311 let mut data = Vec::with_capacity(2);
312
313 if extract_bbo_as_quotes
314 && let Some(quote) = parse_options_chain_record_as_quote(
315 record,
316 precision.price,
317 precision.size,
318 instrument_id,
319 )?
320 {
321 data.push(Data::Quote(quote));
322 }
323
324 data.push(Data::OptionGreeks(parse_options_chain_record(
325 record,
326 instrument_id,
327 )));
328 Ok(data)
329}
330
331fn write_data_buffer(catalog: &ParquetDataCatalog, data: &mut Vec<Data>) -> anyhow::Result<()> {
332 if data.is_empty() {
333 return Ok(());
334 }
335
336 let mut data_by_instrument: AHashMap<InstrumentId, Vec<Data>> = AHashMap::new();
337 for item in data.drain(..) {
338 data_by_instrument
339 .entry(item.instrument_id())
340 .or_default()
341 .push(item);
342 }
343
344 for instrument_data in data_by_instrument.values_mut() {
345 instrument_data.sort_by_key(HasTsInit::ts_init);
346 catalog.write_data_enum(instrument_data, None, None, None)?;
347 }
348
349 data.clear();
350 Ok(())
351}
352
353fn build_instruments(
354 instrument_states: AHashMap<InstrumentId, InstrumentBuildState>,
355 precision_by_instrument: &AHashMap<InstrumentId, OptionsChainPrecision>,
356) -> anyhow::Result<Vec<InstrumentAny>> {
357 let mut states = instrument_states.into_iter().collect::<Vec<_>>();
358 states.sort_by_key(|(instrument_id, _)| instrument_id.to_string());
359
360 states
361 .into_iter()
362 .map(|(instrument_id, state)| {
363 let precision = precision_by_instrument
364 .get(&instrument_id)
365 .copied()
366 .unwrap_or_else(|| OptionsChainPrecision::new(None, None));
367 create_crypto_option_from_options_chain_record(
368 &state.record,
369 instrument_id,
370 state.activation,
371 precision,
372 )
373 })
374 .collect()
375}
376
377fn create_crypto_option_from_options_chain_record(
378 record: &TardisOptionsChainRecord,
379 instrument_id: InstrumentId,
380 activation: UnixNanos,
381 precision: OptionsChainPrecision,
382) -> anyhow::Result<InstrumentAny> {
383 let underlying = record
384 .symbol
385 .as_str()
386 .split_once('-')
387 .map(|(underlying, _)| underlying)
388 .context("options_chain symbol missing underlying prefix")?;
389 let underlying_currency = Currency::get_or_create_crypto(underlying);
390 let (quote_currency, settlement_currency, is_inverse) =
391 option_currency_mapping(record.exchange, underlying_currency)?;
392 let instrument_price_precision = precision
393 .price
394 .max(infer_precision(record.strike_price).min(FIXED_PRECISION));
395 let price_increment = decimal_increment_price(instrument_price_precision)?;
396 let size_increment = decimal_increment_quantity(precision.size)?;
397 let strike_price = Price::from_decimal_dp(
398 Decimal::try_from(record.strike_price)?,
399 instrument_price_precision,
400 )?;
401 let expiration = parse_timestamp(record.expiration);
402 let option_kind = option_kind(record.option_type);
403
404 Ok(InstrumentAny::CryptoOption(
405 CryptoOption::builder()
406 .instrument_id(instrument_id)
407 .raw_symbol(Symbol::from_ustr_unchecked(record.symbol))
408 .underlying(underlying_currency)
409 .quote_currency(quote_currency)
410 .settlement_currency(settlement_currency)
411 .is_inverse(is_inverse)
412 .option_kind(option_kind)
413 .strike_price(strike_price)
414 .activation_ns(activation)
415 .expiration_ns(expiration)
416 .price_precision(instrument_price_precision)
417 .size_precision(precision.size)
418 .price_increment(price_increment)
419 .size_increment(size_increment)
420 .lot_size(size_increment)
421 .min_quantity(size_increment)
422 .ts_event(parse_timestamp(record.timestamp))
423 .ts_init(parse_timestamp(record.local_timestamp))
424 .build()?,
425 ))
426}
427
428fn option_currency_mapping(
429 exchange: TardisExchange,
430 underlying_currency: Currency,
431) -> anyhow::Result<(Currency, Currency, bool)> {
432 match exchange {
433 TardisExchange::Deribit => Ok((underlying_currency, underlying_currency, true)),
434 exchange => anyhow::bail!(
435 "options_chain instrument derivation supports Deribit only, received {exchange}"
436 ),
437 }
438}
439
440fn decimal_increment_price(precision: u8) -> anyhow::Result<Price> {
441 Ok(Price::from_decimal_dp(
442 Decimal::new(1, u32::from(precision)),
443 precision,
444 )?)
445}
446
447fn decimal_increment_quantity(precision: u8) -> anyhow::Result<Quantity> {
448 Ok(Quantity::from_decimal_dp(
449 Decimal::new(1, u32::from(precision)),
450 precision,
451 )?)
452}
453
454const fn option_kind(value: TardisOptionType) -> OptionKind {
455 parse_option_kind(value)
456}
457
458#[cfg(test)]
459mod tests {
460 use std::{
461 fs,
462 path::{Path, PathBuf},
463 time::Duration,
464 };
465
466 use nautilus_model::{
467 data::{OptionGreeks, QuoteTick},
468 enums::OptionKind,
469 instruments::Instrument,
470 };
471 use nautilus_persistence::backend::parquet::catalog::ParquetDataCatalog;
472 use rstest::rstest;
473 use tempfile::TempDir;
474 use ustr::Ustr;
475
476 use super::*;
477 use crate::common::testing::get_test_data_path;
478
479 #[rstest]
480 fn test_options_chain_converter_config_defaults_extract_bbo_as_quotes() {
481 let config = TardisOptionsChainCSVConverterConfig::builder()
482 .filepaths(Vec::<PathBuf>::new())
483 .catalog_path(PathBuf::from("/tmp/options-chain-catalog"))
484 .build();
485
486 assert!(config.extract_bbo_as_quotes);
487 assert!(config.write_instruments);
488 }
489
490 #[rstest]
491 fn test_convert_options_chain_csv_returns_error_for_missing_catalog_directory() {
492 let temp_dir = TempDir::new().unwrap();
493 let catalog_path = temp_dir.path().join("missing");
494 let config = TardisOptionsChainCSVConverterConfig::builder()
495 .filepaths(vec![get_test_data_path("options_chain.csv")])
496 .catalog_path(catalog_path.clone())
497 .build();
498
499 let error = convert_options_chain_csv(&config).unwrap_err();
500
501 assert_eq!(
502 error.to_string(),
503 format!(
504 "failed to open local storage directory '{}'; create it if it does not exist \
505 and check access permissions",
506 catalog_path.display()
507 )
508 );
509 assert!(!catalog_path.exists());
510 }
511
512 #[rstest]
513 fn test_convert_options_chain_csv_thins_and_round_trips_catalog() {
514 let temp_dir = TempDir::new().unwrap();
515 let filepath = get_test_data_path("options_chain.csv");
516 let config = TardisOptionsChainCSVConverterConfig {
517 filepaths: vec![filepath],
518 catalog_path: temp_dir.path().to_path_buf(),
519 underlyings: Some(vec!["BTC-9JUN20-9875".to_string()]),
520 snapshot_interval: Some(Duration::from_secs(60)),
521 extract_bbo_as_quotes: true,
522 write_instruments: true,
523 price_precision: None,
524 size_precision: None,
525 };
526
527 convert_options_chain_csv(&config).unwrap();
528
529 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
530 let instrument_id = "BTC-9JUN20-9875-P.DERIBIT".to_string();
531 let quotes = catalog
532 .query::<QuoteTick>(
533 Some(vec![instrument_id.clone()]),
534 None,
535 None,
536 None,
537 None,
538 true,
539 )
540 .unwrap();
541 let greeks = catalog
542 .query::<OptionGreeks>(
543 Some(vec![instrument_id.clone()]),
544 None,
545 None,
546 None,
547 None,
548 true,
549 )
550 .unwrap();
551 let instruments = catalog
552 .query_instruments(Some(std::slice::from_ref(&instrument_id)))
553 .unwrap();
554
555 assert_eq!(quotes.len(), 2);
556 assert_eq!(greeks.len(), 2);
557 assert_eq!(instruments.len(), 1);
558
559 assert_eq!(quotes[0].bid_price, Price::from("0.0206"));
560 assert_eq!(quotes[0].ask_price, Price::from("0.0236"));
561 assert_eq!(quotes[0].ts_init.as_u64(), 1_591_574_400_473_112_000);
562 assert_eq!(quotes[1].bid_price, Price::from("0.0207"));
563 assert_eq!(quotes[1].ts_init.as_u64(), 1_591_574_460_473_112_000);
564
565 let InstrumentAny::CryptoOption(option) = &instruments[0] else {
566 panic!("Expected CryptoOption");
567 };
568 assert_eq!(option.id().to_string(), instrument_id);
569 assert_eq!(option.raw_symbol(), Symbol::from("BTC-9JUN20-9875-P"));
570 assert_eq!(option.option_kind, OptionKind::Put);
571 assert_eq!(option.strike_price, Price::from("9875.0000"));
572 assert_eq!(
573 option.expiration_ns,
574 UnixNanos::from(1_591_689_600_000_000_000)
575 );
576 assert_eq!(
577 option.activation_ns,
578 UnixNanos::from(1_591_574_400_196_008_000)
579 );
580 assert_eq!(option.price_increment, Price::from("0.0001"));
581 assert_eq!(option.size_increment, Quantity::from("0.1"));
582 assert!(option.is_inverse);
583 assert_eq!(option.quote_currency, Currency::from("BTC"));
584 assert_eq!(option.settlement_currency, Currency::from("BTC"));
585 }
586
587 #[rstest]
588 fn test_convert_options_chain_csv_thinned_multi_instrument_writes_separate_catalogs() {
589 let temp_dir = TempDir::new().unwrap();
590 let filepath = get_test_data_path("options_chain.csv");
591 let config = TardisOptionsChainCSVConverterConfig {
592 filepaths: vec![filepath],
593 catalog_path: temp_dir.path().to_path_buf(),
594 underlyings: Some(vec!["BTC-".to_string()]),
595 snapshot_interval: Some(Duration::from_secs(60)),
596 extract_bbo_as_quotes: true,
597 write_instruments: false,
598 price_precision: None,
599 size_precision: None,
600 };
601
602 convert_options_chain_csv(&config).unwrap();
603
604 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
605 let call_id = "BTC-9JUN20-10000-C.DERIBIT".to_string();
606 let next_expiry_id = "BTC-10JUN20-10000-C.DERIBIT".to_string();
607 let call_quotes = catalog
608 .query::<QuoteTick>(Some(vec![call_id.clone()]), None, None, None, None, true)
609 .unwrap();
610 let next_expiry_greeks = catalog
611 .query::<OptionGreeks>(
612 Some(vec![next_expiry_id.clone()]),
613 None,
614 None,
615 None,
616 None,
617 true,
618 )
619 .unwrap();
620
621 assert_eq!(call_quotes.len(), 1);
622 assert_eq!(call_quotes[0].instrument_id.to_string(), call_id);
623 assert_eq!(call_quotes[0].bid_price, Price::from("0.0305"));
624 assert_eq!(next_expiry_greeks.len(), 1);
625 assert_eq!(
626 next_expiry_greeks[0].instrument_id.to_string(),
627 next_expiry_id
628 );
629 assert_eq!(next_expiry_greeks[0].greeks.delta, 0.0);
630 }
631
632 #[rstest]
633 fn test_convert_options_chain_csv_unthinned_round_trips_catalog() {
634 let temp_dir = TempDir::new().unwrap();
635 let filepath = get_test_data_path("options_chain.csv");
636 let config = TardisOptionsChainCSVConverterConfig {
637 filepaths: vec![filepath],
638 catalog_path: temp_dir.path().to_path_buf(),
639 underlyings: Some(vec!["BTC-9JUN20-9875".to_string()]),
640 snapshot_interval: None,
641 extract_bbo_as_quotes: true,
642 write_instruments: false,
643 price_precision: None,
644 size_precision: None,
645 };
646
647 convert_options_chain_csv(&config).unwrap();
648
649 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
650 let instrument_id = "BTC-9JUN20-9875-P.DERIBIT".to_string();
651 let quotes = catalog
652 .query::<QuoteTick>(
653 Some(vec![instrument_id.clone()]),
654 None,
655 None,
656 None,
657 None,
658 true,
659 )
660 .unwrap();
661 let greeks = catalog
662 .query::<OptionGreeks>(
663 Some(vec![instrument_id.clone()]),
664 None,
665 None,
666 None,
667 None,
668 true,
669 )
670 .unwrap();
671
672 assert_eq!(quotes.len(), 3);
673 assert_eq!(greeks.len(), 3);
674 assert_eq!(quotes[0].bid_price, Price::from("0.0205"));
675 assert_eq!(quotes[1].bid_price, Price::from("0.0206"));
676 assert_eq!(quotes[2].bid_price, Price::from("0.0207"));
677 assert_eq!(quotes[0].ts_init.as_u64(), 1_591_574_400_196_008_000);
678 assert_eq!(quotes[2].ts_init.as_u64(), 1_591_574_460_473_112_000);
679 assert!(
680 greeks
681 .iter()
682 .all(|greek| greek.instrument_id.to_string() == instrument_id)
683 );
684 }
685
686 #[rstest]
687 fn test_convert_options_chain_csv_can_suppress_bbo_quotes() {
688 let temp_dir = TempDir::new().unwrap();
689 let filepath = get_test_data_path("options_chain.csv");
690 let config = TardisOptionsChainCSVConverterConfig {
691 filepaths: vec![filepath],
692 catalog_path: temp_dir.path().to_path_buf(),
693 underlyings: Some(vec!["BTC-9JUN20-9875".to_string()]),
694 snapshot_interval: None,
695 extract_bbo_as_quotes: false,
696 write_instruments: false,
697 price_precision: None,
698 size_precision: None,
699 };
700
701 convert_options_chain_csv(&config).unwrap();
702
703 let mut catalog = ParquetDataCatalog::new(temp_dir.path(), None, None, None, None);
704 let instrument_id = "BTC-9JUN20-9875-P.DERIBIT".to_string();
705 let quotes = catalog
706 .query::<QuoteTick>(
707 Some(vec![instrument_id.clone()]),
708 None,
709 None,
710 None,
711 None,
712 true,
713 )
714 .unwrap();
715 let greeks = catalog
716 .query::<OptionGreeks>(
717 Some(vec![instrument_id.clone()]),
718 None,
719 None,
720 None,
721 None,
722 true,
723 )
724 .unwrap();
725
726 assert!(quotes.is_empty());
727 assert_eq!(greeks.len(), 3);
728 assert!(
729 greeks
730 .iter()
731 .all(|greek| greek.instrument_id.to_string() == instrument_id)
732 );
733 }
734
735 #[rstest]
736 fn test_convert_options_chain_csv_thinned_multi_file_resets_bucket_state() {
737 let temp_dir = TempDir::new().unwrap();
738 let fixture = fs::read_to_string(get_test_data_path("options_chain.csv")).unwrap();
739 let lines = fixture.lines().collect::<Vec<_>>();
740 let first_filepath = temp_dir.path().join("late_btc_options_chain.csv");
741 let second_filepath = temp_dir.path().join("early_eth_options_chain.csv");
742 let catalog_path = temp_dir.path().join("catalog");
743 fs::create_dir(&catalog_path).unwrap();
744 write_options_chain_rows(&first_filepath, lines[0], &[lines[7]]);
745 write_options_chain_rows(&second_filepath, lines[0], &[lines[5]]);
746
747 let config = TardisOptionsChainCSVConverterConfig {
748 filepaths: vec![first_filepath, second_filepath],
749 catalog_path: catalog_path.clone(),
750 underlyings: None,
751 snapshot_interval: Some(Duration::from_secs(60)),
752 extract_bbo_as_quotes: true,
753 write_instruments: false,
754 price_precision: None,
755 size_precision: None,
756 };
757
758 convert_options_chain_csv(&config).unwrap();
759
760 let mut catalog = ParquetDataCatalog::new(&catalog_path, None, None, None, None);
761 let btc_id = "BTC-9JUN20-9875-P.DERIBIT".to_string();
762 let eth_id = "ETH-9JUN20-250-P.DERIBIT".to_string();
763 let btc_quotes = catalog
764 .query::<QuoteTick>(Some(vec![btc_id.clone()]), None, None, None, None, true)
765 .unwrap();
766 let eth_quotes = catalog
767 .query::<QuoteTick>(Some(vec![eth_id.clone()]), None, None, None, None, true)
768 .unwrap();
769 let eth_greeks = catalog
770 .query::<OptionGreeks>(Some(vec![eth_id.clone()]), None, None, None, None, true)
771 .unwrap();
772
773 assert_eq!(btc_quotes.len(), 1);
774 assert_eq!(btc_quotes[0].instrument_id.to_string(), btc_id);
775 assert_eq!(btc_quotes[0].bid_price, Price::from("0.0207"));
776 assert_eq!(eth_quotes.len(), 1);
777 assert_eq!(eth_quotes[0].instrument_id.to_string(), eth_id);
778 assert_eq!(eth_quotes[0].bid_price, Price::from("0.12345"));
779 assert_eq!(eth_greeks.len(), 1);
780 assert_eq!(eth_greeks[0].instrument_id.to_string(), eth_id);
781 }
782
783 #[rstest]
784 fn test_create_crypto_option_preserves_fractional_strike_and_new_underlying() {
785 let record = TardisOptionsChainRecord {
786 exchange: TardisExchange::Deribit,
787 symbol: Ustr::from("NEW-9JUN20-2.05-C"),
788 timestamp: 1,
789 local_timestamp: 2,
790 option_type: TardisOptionType::Call,
791 strike_price: 2.05,
792 expiration: 1_591_689_600_000_000,
793 open_interest: None,
794 last_price: Some(0.1),
795 bid_price: Some(0.1),
796 bid_amount: Some(1.0),
797 bid_iv: None,
798 ask_price: Some(0.2),
799 ask_amount: Some(1.0),
800 ask_iv: None,
801 mark_price: None,
802 mark_iv: None,
803 underlying_index: "SYN.NEW-9JUN20".to_string(),
804 underlying_price: Some(2.0),
805 delta: None,
806 gamma: None,
807 vega: None,
808 theta: None,
809 rho: None,
810 };
811 let instrument_id = InstrumentId::from("NEW-9JUN20-2.05-C.DERIBIT");
812 let instrument = create_crypto_option_from_options_chain_record(
813 &record,
814 instrument_id,
815 UnixNanos::from(2_000),
816 OptionsChainPrecision { price: 1, size: 0 },
817 )
818 .unwrap();
819
820 let InstrumentAny::CryptoOption(option) = instrument else {
821 panic!("Expected CryptoOption");
822 };
823
824 assert_eq!(option.underlying, Currency::get_or_create_crypto("NEW"));
825 assert_eq!(option.strike_price, Price::from("2.05"));
826 assert_eq!(option.price_precision, 2);
827 assert_eq!(option.price_increment, Price::from("0.01"));
828 }
829
830 fn write_options_chain_rows(path: &Path, header: &str, rows: &[&str]) {
831 let mut contents = String::from(header);
832 contents.push('\n');
833 for row in rows {
834 contents.push_str(row);
835 contents.push('\n');
836 }
837 fs::write(path, contents).unwrap();
838 }
839}