1use std::{
19 collections::{BTreeMap, HashMap},
20 fmt::{Display, Write},
21 str::FromStr,
22 sync::Arc,
23};
24
25use arrow::{
26 array::{
27 Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeBinaryArray, FixedSizeListArray,
28 StringArray, StringBuilder, TimestampNanosecondArray, UInt8Array, UInt8Builder,
29 UInt16Array, UInt64Array, UInt64Builder, new_null_array,
30 },
31 compute::cast,
32 datatypes::{DataType, Field, Schema, TimeUnit},
33 error::ArrowError,
34 record_batch::RecordBatch,
35};
36use nautilus_model::{
37 data::{
38 Bar, DEPTH10_LEN, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
39 MarkPriceUpdate, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick,
40 },
41 enums::{AggressorSide, BookAction, FromU8, InstrumentCloseType, OrderSide},
42 types::Price,
43};
44
45use super::{
46 ArrowSchemaProvider, FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE, KEY_IDENTIFIER,
47 KEY_INSTRUMENT_ID, KEY_PRICE_PRECISION, STANDARD_TO_DECIMAL_SCALE, enum_dictionary_array,
48 enum_dictionary_data_type, fixed_decimal_data_type, price_decimal_array,
49 schema_without_identifier_column, timestamp_column, timestamp_data_type,
50};
51
52#[derive(Clone, Debug, Eq, Hash, PartialEq)]
54pub struct SchemaFingerprint(String);
55
56impl Display for SchemaFingerprint {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 f.write_str(&self.0)
59 }
60}
61
62#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum LegacyTranscodeKind {
65 PassThrough,
66 InstrumentStatusV1,
67 FundingRateUpdateV1,
68 InstrumentCloseV1,
69}
70
71#[derive(Debug)]
73pub struct LegacyTranscodeResult {
74 pub batches: Vec<RecordBatch>,
75 pub kind: LegacyTranscodeKind,
76}
77
78#[derive(Debug, Default)]
80pub struct LegacyTranscodeState {
81 instrument_close_precisions: BTreeMap<String, u8>,
82}
83
84#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct LegacySchemaResolution {
87 pub kind: LegacyTranscodeKind,
88 pub source_fingerprint: SchemaFingerprint,
89 pub target_fingerprint: SchemaFingerprint,
90}
91
92#[derive(Debug, thiserror::Error)]
94pub enum LegacyArrowError {
95 #[error(
96 "No Arrow transcoder is registered for type {type_name}, file {file_path}, \
97 and schema fingerprint {fingerprint}"
98 )]
99 UnknownSchema {
100 type_name: String,
101 file_path: String,
102 fingerprint: SchemaFingerprint,
103 },
104 #[error("Failed to transcode type {type_name} from {file_path}: {message}")]
105 Transcode {
106 type_name: String,
107 file_path: String,
108 message: String,
109 },
110}
111
112#[must_use]
114pub fn schema_fingerprint(schema: &Schema) -> SchemaFingerprint {
115 let mut value = String::new();
116
117 for field in schema.fields() {
118 write!(
119 value,
120 "{}:{:?}:{};",
121 field.name(),
122 field.data_type(),
123 field.is_nullable()
124 )
125 .expect("writing a schema fingerprint to a string cannot fail");
126 }
127 SchemaFingerprint(value)
128}
129
130pub fn transcode_legacy_record_batch(
138 type_name: &str,
139 file_path: &str,
140 batch: RecordBatch,
141) -> Result<LegacyTranscodeResult, LegacyArrowError> {
142 transcode_legacy_record_batch_with_state(
143 type_name,
144 file_path,
145 batch,
146 &mut LegacyTranscodeState::default(),
147 )
148}
149
150pub fn transcode_legacy_record_batch_with_state(
158 type_name: &str,
159 file_path: &str,
160 batch: RecordBatch,
161 state: &mut LegacyTranscodeState,
162) -> Result<LegacyTranscodeResult, LegacyArrowError> {
163 let resolution = resolve_legacy_schema(type_name, file_path, batch.schema().as_ref())?;
164 if resolution.kind == LegacyTranscodeKind::PassThrough {
165 return Ok(pass_through(batch));
166 }
167 let batches = match resolution.kind {
168 LegacyTranscodeKind::InstrumentStatusV1 => {
169 vec![transcode_instrument_status(&batch, file_path)?]
170 }
171 LegacyTranscodeKind::FundingRateUpdateV1 => {
172 vec![transcode_funding_rate(&batch, file_path)?]
173 }
174 LegacyTranscodeKind::InstrumentCloseV1 => {
175 transcode_instrument_close(&batch, file_path, state)?
176 }
177 LegacyTranscodeKind::PassThrough => {
178 unreachable!("pass-through schemas are handled before legacy registry lookup")
179 }
180 };
181
182 Ok(LegacyTranscodeResult {
183 batches,
184 kind: resolution.kind,
185 })
186}
187
188pub fn resolve_legacy_schema(
194 type_name: &str,
195 file_path: &str,
196 schema: &Schema,
197) -> Result<LegacySchemaResolution, LegacyArrowError> {
198 let source_fingerprint = schema_fingerprint(schema);
199 let Some(current_schema) = current_schema(type_name, schema.metadata().clone()) else {
200 return Ok(LegacySchemaResolution {
201 kind: LegacyTranscodeKind::PassThrough,
202 target_fingerprint: source_fingerprint.clone(),
203 source_fingerprint,
204 });
205 };
206 let target_fingerprint = schema_fingerprint(¤t_schema);
207 let current_without_identifier =
208 schema_fingerprint(&schema_without_identifier_column(¤t_schema));
209 let current_plain_strings = schema_with_plain_dictionary_strings(¤t_schema);
210 let current_plain_strings_fingerprint = schema_fingerprint(¤t_plain_strings);
211 let current_plain_strings_without_identifier =
212 schema_fingerprint(&schema_without_identifier_column(¤t_plain_strings));
213
214 if source_fingerprint == target_fingerprint
215 || source_fingerprint == current_without_identifier
216 || source_fingerprint == current_plain_strings_fingerprint
217 || source_fingerprint == current_plain_strings_without_identifier
218 {
219 return Ok(LegacySchemaResolution {
220 kind: LegacyTranscodeKind::PassThrough,
221 source_fingerprint,
222 target_fingerprint,
223 });
224 }
225
226 let kind = registered_legacy_kind(type_name, &source_fingerprint).ok_or_else(|| {
227 LegacyArrowError::UnknownSchema {
228 type_name: type_name.to_string(),
229 file_path: file_path.to_string(),
230 fingerprint: source_fingerprint.clone(),
231 }
232 })?;
233 Ok(LegacySchemaResolution {
234 kind,
235 source_fingerprint,
236 target_fingerprint,
237 })
238}
239
240fn schema_with_plain_dictionary_strings(schema: &Schema) -> Schema {
241 let fields = schema
242 .fields()
243 .iter()
244 .map(|field| {
245 let data_type = match field.data_type() {
246 DataType::Dictionary(_, value_type)
247 if matches!(value_type.as_ref(), DataType::Utf8) =>
248 {
249 DataType::Utf8
250 }
251 data_type => data_type.clone(),
252 };
253 field.as_ref().clone().with_data_type(data_type)
254 })
255 .collect::<Vec<_>>();
256 Schema::new_with_metadata(fields, schema.metadata().clone())
257}
258
259fn pass_through(batch: RecordBatch) -> LegacyTranscodeResult {
260 LegacyTranscodeResult {
261 batches: vec![batch],
262 kind: LegacyTranscodeKind::PassThrough,
263 }
264}
265
266fn current_schema(type_name: &str, metadata: HashMap<String, String>) -> Option<Schema> {
267 let metadata = Some(metadata);
268 match type_name {
269 "quotes" => Some(QuoteTick::get_schema(metadata)),
270 "trades" => Some(TradeTick::get_schema(metadata)),
271 "bars" => Some(Bar::get_schema(metadata)),
272 "order_book_deltas" => Some(OrderBookDelta::get_schema(metadata)),
273 "order_book_depths" => Some(OrderBookDepth::get_schema(metadata)),
274 "mark_prices" => Some(MarkPriceUpdate::get_schema(metadata)),
275 "index_prices" => Some(IndexPriceUpdate::get_schema(metadata)),
276 "funding_rates" => Some(FundingRateUpdate::get_schema(metadata)),
277 "instrument_status" => Some(InstrumentStatus::get_schema(metadata)),
278 "option_greeks" => Some(OptionGreeks::get_schema(metadata)),
279 "instrument_closes" => Some(InstrumentClose::get_schema(metadata)),
280 _ => None,
281 }
282}
283
284fn registered_legacy_kind(
285 type_name: &str,
286 fingerprint: &SchemaFingerprint,
287) -> Option<LegacyTranscodeKind> {
288 let entries = [
289 (
290 "instrument_status",
291 legacy_instrument_status_schema(),
292 LegacyTranscodeKind::InstrumentStatusV1,
293 ),
294 (
295 "funding_rates",
296 legacy_funding_rate_schema(),
297 LegacyTranscodeKind::FundingRateUpdateV1,
298 ),
299 (
300 "instrument_closes",
301 legacy_instrument_close_schema(),
302 LegacyTranscodeKind::InstrumentCloseV1,
303 ),
304 ];
305
306 entries
307 .into_iter()
308 .find(|(entry_type, schema, _)| {
309 type_name == *entry_type
310 && (schema_fingerprint(schema) == *fingerprint
311 || schema_fingerprint(&schema_with_normalized_legacy_types(schema))
312 == *fingerprint)
313 })
314 .map(|(_, _, kind)| kind)
315}
316
317fn schema_with_normalized_legacy_types(schema: &Schema) -> Schema {
318 let fields = schema
319 .fields()
320 .iter()
321 .map(|field| {
322 Arc::new(
323 field
324 .as_ref()
325 .clone()
326 .with_data_type(normalized_legacy_data_type(field.name(), field.data_type())),
327 )
328 })
329 .collect::<Vec<_>>();
330 Schema::new_with_metadata(fields, schema.metadata().clone())
331}
332
333fn legacy_instrument_status_schema() -> Schema {
334 Schema::new(vec![
335 Field::new("instrument_id", DataType::Utf8, true),
336 Field::new("action", DataType::Utf8, true),
337 Field::new("reason", DataType::Utf8, true),
338 Field::new("trading_event", DataType::Utf8, true),
339 Field::new("is_trading", DataType::Boolean, true),
340 Field::new("is_quoting", DataType::Boolean, true),
341 Field::new("is_short_sell_restricted", DataType::Boolean, true),
342 Field::new("ts_event", DataType::UInt64, true),
343 Field::new("ts_init", DataType::UInt64, true),
344 ])
345}
346
347fn legacy_funding_rate_schema() -> Schema {
348 Schema::new(vec![
349 Field::new("rate", DataType::Binary, false),
350 Field::new("interval", DataType::UInt16, true),
351 Field::new("next_funding_ns", DataType::UInt64, true),
352 Field::new("ts_event", DataType::UInt64, false),
353 Field::new("ts_init", DataType::UInt64, false),
354 ])
355}
356
357fn legacy_instrument_close_schema() -> Schema {
358 Schema::new(vec![
359 Field::new("instrument_id", DataType::Utf8, true),
360 Field::new("close_type", DataType::Utf8, true),
361 Field::new("close_price", DataType::Utf8, true),
362 Field::new("ts_event", DataType::UInt64, true),
363 Field::new("ts_init", DataType::UInt64, true),
364 ])
365}
366
367fn transcode_instrument_status(
368 batch: &RecordBatch,
369 file_path: &str,
370) -> Result<RecordBatch, LegacyArrowError> {
371 let mut metadata = batch.schema().metadata().clone();
372 metadata.insert("type".to_string(), "InstrumentStatus".to_string());
373 let schema = InstrumentStatus::get_schema(Some(metadata));
374 let instrument_id = required_column(batch, "instrument_id", file_path)?;
375 let mut columns = Vec::with_capacity(schema.fields().len());
376
377 for field in schema.fields() {
378 let column = if field.name() == KEY_IDENTIFIER {
379 instrument_id.clone()
380 } else if let Some(column) = batch.column_by_name(field.name()) {
381 column.clone()
382 } else if field.is_nullable() {
383 new_null_array(field.data_type(), batch.num_rows())
384 } else {
385 return Err(transcode_error(
386 "instrument_status",
387 file_path,
388 format!("required field {} is absent", field.name()),
389 ));
390 };
391
392 let convertible_timestamp = field.data_type() == &super::timestamp_data_type()
393 && column.data_type() == &DataType::UInt64;
394 if column.data_type() != field.data_type() && !convertible_timestamp {
395 return Err(transcode_error(
396 "instrument_status",
397 file_path,
398 format!(
399 "field {} expected {:?}, found {:?}",
400 field.name(),
401 field.data_type(),
402 column.data_type()
403 ),
404 ));
405 }
406
407 if !field.is_nullable() && column.null_count() > 0 {
408 return Err(transcode_error(
409 "instrument_status",
410 file_path,
411 format!("required field {} contains nulls", field.name()),
412 ));
413 }
414 columns.push(column);
415 }
416
417 super::record_batch_with_timestamps(Arc::new(schema), columns)
418 .map_err(|e| transcode_error("instrument_status", file_path, e.to_string()))
419}
420
421fn transcode_funding_rate(
422 batch: &RecordBatch,
423 file_path: &str,
424) -> Result<RecordBatch, LegacyArrowError> {
425 let instrument_id = batch
426 .schema()
427 .metadata()
428 .get(KEY_INSTRUMENT_ID)
429 .cloned()
430 .ok_or_else(|| {
431 transcode_error(
432 "funding_rates",
433 file_path,
434 "instrument_id schema metadata is absent",
435 )
436 })?;
437 let rate = required_typed_column::<BinaryArray>(batch, "rate", file_path, "funding_rates")?;
438 let interval =
439 required_typed_column::<UInt16Array>(batch, "interval", file_path, "funding_rates")?;
440 let mut rate_builder = StringBuilder::with_capacity(batch.num_rows(), rate.value_data().len());
441
442 for row in 0..batch.num_rows() {
443 if rate.is_null(row) {
444 return Err(transcode_error(
445 "funding_rates",
446 file_path,
447 format!("required field rate is null at row {row}"),
448 ));
449 }
450 let value = serde_json::from_slice::<String>(rate.value(row)).map_err(|e| {
451 transcode_error(
452 "funding_rates",
453 file_path,
454 format!("rate at row {row} is not a msgspec JSON string: {e}"),
455 )
456 })?;
457 rate_builder.append_value(value);
458 }
459
460 let mut metadata = batch.schema().metadata().clone();
461 metadata.insert("type".to_string(), "FundingRateUpdate".to_string());
462 metadata.insert(KEY_INSTRUMENT_ID.to_string(), instrument_id.clone());
463 let schema = FundingRateUpdate::get_schema(Some(metadata));
464 let identifiers = StringArray::from(vec![instrument_id; batch.num_rows()]);
465 let columns: Vec<ArrayRef> = vec![
466 Arc::new(identifiers.clone()),
467 Arc::new(rate_builder.finish()),
468 cast(interval, &DataType::UInt64)
469 .map_err(|e| transcode_error("funding_rates", file_path, e.to_string()))?,
470 required_column(batch, "next_funding_ns", file_path)?,
471 required_column(batch, "ts_event", file_path)?,
472 required_column(batch, "ts_init", file_path)?,
473 Arc::new(identifiers),
474 ];
475
476 super::record_batch_with_timestamps(Arc::new(schema), columns)
477 .map_err(|e| transcode_error("funding_rates", file_path, e.to_string()))
478}
479
480fn transcode_instrument_close(
481 batch: &RecordBatch,
482 file_path: &str,
483 state: &mut LegacyTranscodeState,
484) -> Result<Vec<RecordBatch>, LegacyArrowError> {
485 let normalized = super::record_batch_with_u64_timestamps(batch)
486 .map_err(|e| transcode_error("instrument_closes", file_path, e.to_string()))?;
487 let batch = &normalized;
488 let instrument_ids = required_typed_column::<StringArray>(
489 batch,
490 "instrument_id",
491 file_path,
492 "instrument_closes",
493 )?;
494 let close_types =
495 required_typed_column::<StringArray>(batch, "close_type", file_path, "instrument_closes")?;
496 let close_prices =
497 required_typed_column::<StringArray>(batch, "close_price", file_path, "instrument_closes")?;
498 let ts_events =
499 required_typed_column::<UInt64Array>(batch, "ts_event", file_path, "instrument_closes")?;
500 let ts_inits =
501 required_typed_column::<UInt64Array>(batch, "ts_init", file_path, "instrument_closes")?;
502 let mut rows_by_instrument: BTreeMap<String, Vec<usize>> = BTreeMap::new();
503
504 for row in 0..batch.num_rows() {
505 if instrument_ids.is_null(row) {
506 return Err(transcode_error(
507 "instrument_closes",
508 file_path,
509 format!("instrument_id is null at row {row}"),
510 ));
511 }
512 rows_by_instrument
513 .entry(instrument_ids.value(row).to_string())
514 .or_default()
515 .push(row);
516 }
517
518 let mut batches = Vec::with_capacity(rows_by_instrument.len());
519 for (instrument_id, rows) in rows_by_instrument {
520 let (batch, precision) = transcode_instrument_close_rows(
521 batch,
522 file_path,
523 &instrument_id,
524 &rows,
525 close_types,
526 close_prices,
527 ts_events,
528 ts_inits,
529 state
530 .instrument_close_precisions
531 .get(&instrument_id)
532 .copied(),
533 )?;
534 state
535 .instrument_close_precisions
536 .insert(instrument_id, precision);
537 batches.push(batch);
538 }
539 Ok(batches)
540}
541
542#[expect(
543 clippy::too_many_arguments,
544 reason = "the arguments are the validated source columns for one legacy close batch"
545)]
546fn transcode_instrument_close_rows(
547 batch: &RecordBatch,
548 file_path: &str,
549 instrument_id: &str,
550 rows: &[usize],
551 close_types: &StringArray,
552 close_prices: &StringArray,
553 ts_events: &UInt64Array,
554 ts_inits: &UInt64Array,
555 expected_precision: Option<u8>,
556) -> Result<(RecordBatch, u8), LegacyArrowError> {
557 let mut prices = Vec::with_capacity(rows.len());
558 let mut type_builder = UInt8Builder::with_capacity(rows.len());
559 let mut event_builder = UInt64Builder::with_capacity(rows.len());
560 let mut init_builder = UInt64Builder::with_capacity(rows.len());
561 let mut precision = expected_precision;
562
563 for &row in rows {
564 if close_types.is_null(row)
565 || close_prices.is_null(row)
566 || ts_events.is_null(row)
567 || ts_inits.is_null(row)
568 {
569 return Err(transcode_error(
570 "instrument_closes",
571 file_path,
572 format!("required close field is null at row {row}"),
573 ));
574 }
575
576 let value = close_prices.value(row);
577 let row_precision = decimal_precision(value).map_err(|message| {
578 transcode_error(
579 "instrument_closes",
580 file_path,
581 format!("invalid close_price {value:?} at row {row}: {message}"),
582 )
583 })?;
584
585 if let Some(existing) = precision
586 && existing != row_precision
587 {
588 return Err(transcode_error(
589 "instrument_closes",
590 file_path,
591 format!(
592 "close_price precision conflict at row {row}: found {row_precision}, \
593 expected {existing}"
594 ),
595 ));
596 }
597 precision = Some(row_precision);
598
599 let price = Price::from_str(value).map_err(|e| {
600 transcode_error(
601 "instrument_closes",
602 file_path,
603 format!("invalid close_price {value:?} at row {row}: {e}"),
604 )
605 })?;
606 let close_type = InstrumentCloseType::from_str(close_types.value(row)).map_err(|e| {
607 transcode_error(
608 "instrument_closes",
609 file_path,
610 format!(
611 "unknown close_type {:?} at row {row}: {e}",
612 close_types.value(row)
613 ),
614 )
615 })?;
616
617 prices.push(price.raw());
618 type_builder.append_value(close_type as u8);
619 event_builder.append_value(ts_events.value(row));
620 init_builder.append_value(ts_inits.value(row));
621 }
622
623 let precision = precision.unwrap_or(0);
624 let mut metadata = batch.schema().metadata().clone();
625 metadata.insert("type".to_string(), "InstrumentClose".to_string());
626 metadata.insert(KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string());
627 metadata.insert(KEY_PRICE_PRECISION.to_string(), precision.to_string());
628 let schema = InstrumentClose::get_schema(Some(metadata));
629 let identifiers = StringArray::from(vec![instrument_id; rows.len()]);
630
631 let batch = super::record_batch_with_timestamps(
632 Arc::new(schema),
633 vec![
634 Arc::new(
635 price_decimal_array(prices, "close_price")
636 .map_err(|e| transcode_error("instrument_closes", file_path, e.to_string()))?,
637 ),
638 Arc::new(type_builder.finish()),
639 Arc::new(event_builder.finish()),
640 Arc::new(init_builder.finish()),
641 Arc::new(identifiers),
642 ],
643 )
644 .map_err(|e| transcode_error("instrument_closes", file_path, e.to_string()))?;
645 Ok((batch, precision))
646}
647
648fn decimal_precision(value: &str) -> Result<u8, String> {
649 let precision = value
650 .split_once('.')
651 .map_or(0, |(_, fractional)| fractional.len());
652 u8::try_from(precision).map_err(|e| e.to_string())
653}
654
655fn required_column(
656 batch: &RecordBatch,
657 name: &str,
658 file_path: &str,
659) -> Result<ArrayRef, LegacyArrowError> {
660 batch.column_by_name(name).cloned().ok_or_else(|| {
661 transcode_error(
662 "legacy_arrow",
663 file_path,
664 format!("required field {name} is absent"),
665 )
666 })
667}
668
669fn required_typed_column<'a, T: Array + 'static>(
670 batch: &'a RecordBatch,
671 name: &str,
672 file_path: &str,
673 type_name: &str,
674) -> Result<&'a T, LegacyArrowError> {
675 let column = batch.column_by_name(name).ok_or_else(|| {
676 transcode_error(
677 type_name,
678 file_path,
679 format!("required field {name} is absent"),
680 )
681 })?;
682 column.as_any().downcast_ref::<T>().ok_or_else(|| {
683 transcode_error(
684 type_name,
685 file_path,
686 format!("field {name} has unexpected type {:?}", column.data_type()),
687 )
688 })
689}
690
691fn transcode_error(
692 type_name: &str,
693 file_path: &str,
694 message: impl Into<String>,
695) -> LegacyArrowError {
696 LegacyArrowError::Transcode {
697 type_name: type_name.to_string(),
698 file_path: file_path.to_string(),
699 message: message.into(),
700 }
701}
702
703#[must_use]
705pub fn normalized_legacy_data_type(name: &str, data_type: &DataType) -> DataType {
706 match data_type {
707 DataType::FixedSizeBinary(8 | 16) => fixed_decimal_data_type(),
708 DataType::UInt64 if is_timestamp_field(name) => timestamp_data_type(),
709 DataType::Timestamp(TimeUnit::Nanosecond, None) if is_timestamp_field(name) => {
710 timestamp_data_type()
711 }
712 DataType::UInt8 if is_legacy_enum_field(name) => enum_dictionary_data_type(),
713 _ => data_type.clone(),
714 }
715}
716
717pub fn normalize_legacy_fixed_columns(batch: &RecordBatch) -> Result<RecordBatch, ArrowError> {
723 let mut changed = false;
724 let mut fields = Vec::with_capacity(batch.num_columns());
725 let mut columns = Vec::with_capacity(batch.num_columns());
726 let normalize_named_fields = is_nautilus_legacy_schema(batch.schema_ref());
727 let normalize_timestamps = is_nautilus_timestamp_schema(batch.schema_ref());
728
729 for (field, column) in batch.schema().fields().iter().zip(batch.columns()) {
730 let custom_timestamp = batch.schema().metadata().contains_key("type_name")
731 && matches!(field.name().as_str(), "ts_event" | "ts_init");
732 if field.data_type() == &DataType::UInt64
733 && ((normalize_named_fields && is_timestamp_field(field.name())) || custom_timestamp)
734 {
735 let timestamps = timestamp_column(field, column.as_ref())?;
736 fields.push(Arc::new(field.as_ref().clone().with_data_type(
737 normalized_legacy_data_type(field.name(), field.data_type()),
738 )));
739 columns.push(timestamps);
740 changed = true;
741 continue;
742 }
743
744 if normalize_timestamps
745 && normalized_timestamp_type(field.data_type()) != *field.data_type()
746 {
747 fields.push(Arc::new(
748 field.as_ref().clone().with_data_type(timestamp_data_type()),
749 ));
750 let timestamps = column
751 .as_any()
752 .downcast_ref::<TimestampNanosecondArray>()
753 .ok_or_else(|| {
754 ArrowError::CastError(format!(
755 "Column '{}' is not a nanosecond timestamp",
756 field.name()
757 ))
758 })?;
759 columns.push(Arc::new(
760 timestamps.clone().with_data_type(timestamp_data_type()),
761 ));
762 changed = true;
763 continue;
764 }
765
766 if normalize_named_fields
767 && field.data_type() == &DataType::UInt8
768 && is_legacy_enum_field(field.name())
769 {
770 fields.push(Arc::new(field.as_ref().clone().with_data_type(
771 normalized_legacy_data_type(field.name(), field.data_type()),
772 )));
773 columns.push(legacy_enum_dictionary_column(field, column.as_ref())?);
774 changed = true;
775 continue;
776 }
777
778 if normalize_named_fields
779 && let DataType::FixedSizeList(item, length) = field.data_type()
780 && let DataType::FixedSizeBinary(width @ (8 | 16)) = item.data_type()
781 {
782 let list = column
783 .as_any()
784 .downcast_ref::<FixedSizeListArray>()
785 .ok_or_else(|| {
786 ArrowError::CastError(format!("Column '{}' is not FixedSizeList", field.name()))
787 })?;
788 let values = list
789 .values()
790 .as_any()
791 .downcast_ref::<FixedSizeBinaryArray>()
792 .ok_or_else(|| {
793 ArrowError::CastError(format!(
794 "Column '{}' values are not FixedSizeBinary",
795 field.name()
796 ))
797 })?;
798 let decimal = normalize_legacy_fixed_array(field.name(), values, *width)?;
799 let item = Arc::new(
800 item.as_ref()
801 .clone()
802 .with_data_type(fixed_decimal_data_type())
803 .with_nullable(true),
804 );
805 let list = FixedSizeListArray::try_new(
806 Arc::clone(&item),
807 *length,
808 Arc::new(decimal),
809 list.nulls().cloned(),
810 )?;
811 fields.push(Arc::new(
812 field
813 .as_ref()
814 .clone()
815 .with_data_type(DataType::FixedSizeList(item, *length)),
816 ));
817 columns.push(Arc::new(list) as ArrayRef);
818 changed = true;
819 continue;
820 }
821
822 let DataType::FixedSizeBinary(width @ (8 | 16)) = field.data_type() else {
823 fields.push(field.clone());
824 columns.push(column.clone());
825 continue;
826 };
827
828 if !normalize_named_fields {
829 fields.push(field.clone());
830 columns.push(column.clone());
831 continue;
832 }
833 let values = column
834 .as_any()
835 .downcast_ref::<FixedSizeBinaryArray>()
836 .ok_or_else(|| {
837 ArrowError::CastError(format!("Column '{}' is not FixedSizeBinary", field.name()))
838 })?;
839 let decimal = normalize_legacy_fixed_array(field.name(), values, *width)?;
840 fields.push(Arc::new(
841 field
842 .as_ref()
843 .clone()
844 .with_data_type(normalized_legacy_data_type(field.name(), field.data_type()))
845 .with_nullable(true),
846 ));
847 columns.push(Arc::new(decimal) as ArrayRef);
848 changed = true;
849 }
850
851 if !changed {
852 return Ok(batch.clone());
853 }
854
855 let schema = Arc::new(Schema::new_with_metadata(
856 fields,
857 batch.schema().metadata().clone(),
858 ));
859 RecordBatch::try_new(schema, columns)
860}
861
862pub(super) fn legacy_enum_dictionary_column(
863 field: &Field,
864 column: &dyn Array,
865) -> Result<ArrayRef, ArrowError> {
866 let values = column
867 .as_any()
868 .downcast_ref::<UInt8Array>()
869 .ok_or_else(|| ArrowError::CastError(format!("Column '{}' is not UInt8", field.name())))?;
870 let names = (0..values.len())
871 .map(|row| legacy_enum_name(field.name(), values.value(row)))
872 .collect::<Result<Vec<_>, _>>()?;
873 Ok(Arc::new(enum_dictionary_array(names)?) as ArrayRef)
874}
875
876fn normalize_legacy_fixed_array(
877 name: &str,
878 values: &FixedSizeBinaryArray,
879 width: i32,
880) -> Result<Decimal128Array, ArrowError> {
881 let quantity = is_legacy_quantity_field(name);
882 let mut decimals = Vec::with_capacity(values.len());
883
884 for row in 0..values.len() {
885 if values.is_null(row) {
886 decimals.push(None);
887 continue;
888 }
889
890 let bytes = values.value(row);
891 let decimal = match (width, quantity) {
892 (8, false) => {
893 let raw = i64::from_le_bytes(bytes.try_into().map_err(|e| {
894 ArrowError::CastError(format!(
895 "Invalid legacy price column '{name}' at row {row}: {e}"
896 ))
897 })?);
898
899 if raw == i64::MIN {
900 return Err(ArrowError::CastError(format!(
901 "Legacy price column '{name}' contains PRICE_ERROR raw value {raw} at row {row}",
902 )));
903 }
904 (raw != i64::MAX).then_some(i128::from(raw) * STANDARD_TO_DECIMAL_SCALE)
905 }
906 (8, true) => {
907 let raw = u64::from_le_bytes(bytes.try_into().map_err(|e| {
908 ArrowError::CastError(format!(
909 "Invalid legacy quantity column '{name}' at row {row}: {e}"
910 ))
911 })?);
912 (raw != u64::MAX).then_some(i128::from(raw) * STANDARD_TO_DECIMAL_SCALE)
913 }
914 (16, false) => {
915 let raw = i128::from_le_bytes(bytes.try_into().map_err(|e| {
916 ArrowError::CastError(format!(
917 "Invalid legacy price column '{name}' at row {row}: {e}"
918 ))
919 })?);
920
921 if raw == i128::MIN {
922 return Err(ArrowError::CastError(format!(
923 "Legacy price column '{name}' contains PRICE_ERROR raw value {raw} at row {row}",
924 )));
925 }
926 (raw != i128::MAX).then_some(raw)
927 }
928 (16, true) => {
929 let raw = u128::from_le_bytes(bytes.try_into().map_err(|e| {
930 ArrowError::CastError(format!(
931 "Invalid legacy quantity column '{name}' at row {row}: {e}"
932 ))
933 })?);
934
935 if raw == u128::MAX {
936 None
937 } else {
938 Some(i128::try_from(raw).map_err(|_| {
939 ArrowError::CastError(format!(
940 "Legacy quantity column '{name}' exceeds Decimal128 at row {row}"
941 ))
942 })?)
943 }
944 }
945 _ => unreachable!("legacy fixed width checked above"),
946 };
947 decimals.push(decimal);
948 }
949
950 Decimal128Array::from(decimals)
951 .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
952}
953
954type LegacySchemaFields = &'static [(&'static str, LegacyFieldType, bool)];
955
956const LEGACY_SCHEMA_FAMILIES: &[(&[&str], LegacySchemaFields)] = &[
957 (
958 &["OrderBookDelta"],
959 &[
960 ("action", LegacyFieldType::UInt8, false),
961 ("side", LegacyFieldType::UInt8, false),
962 ("price", LegacyFieldType::Fixed, false),
963 ("size", LegacyFieldType::Fixed, false),
964 ("order_id", LegacyFieldType::UInt64, false),
965 ("flags", LegacyFieldType::UInt8, false),
966 ("sequence", LegacyFieldType::UInt64, false),
967 ("ts_event", LegacyFieldType::UInt64, false),
968 ("ts_init", LegacyFieldType::UInt64, false),
969 ],
970 ),
971 (
972 &["TradeTick"],
973 &[
974 ("price", LegacyFieldType::Fixed, false),
975 ("size", LegacyFieldType::Fixed, false),
976 ("aggressor_side", LegacyFieldType::UInt8, false),
977 ("trade_id", LegacyFieldType::Utf8, false),
978 ("ts_event", LegacyFieldType::UInt64, false),
979 ("ts_init", LegacyFieldType::UInt64, false),
980 ],
981 ),
982 (
983 &["InstrumentClose"],
984 &[
985 ("close_price", LegacyFieldType::Fixed, false),
986 ("close_type", LegacyFieldType::UInt8, false),
987 ("ts_event", LegacyFieldType::UInt64, false),
988 ("ts_init", LegacyFieldType::UInt64, false),
989 ],
990 ),
991 (
992 &["InstrumentClose"],
993 &[
994 ("instrument_id", LegacyFieldType::Utf8, true),
995 ("close_type", LegacyFieldType::Utf8, true),
996 ("close_price", LegacyFieldType::Utf8, true),
997 ("ts_event", LegacyFieldType::UInt64, true),
998 ("ts_init", LegacyFieldType::UInt64, true),
999 ],
1000 ),
1001 (
1002 &["QuoteTick"],
1003 &[
1004 ("bid_price", LegacyFieldType::Fixed, false),
1005 ("ask_price", LegacyFieldType::Fixed, false),
1006 ("bid_size", LegacyFieldType::Fixed, false),
1007 ("ask_size", LegacyFieldType::Fixed, false),
1008 ("ts_event", LegacyFieldType::UInt64, false),
1009 ("ts_init", LegacyFieldType::UInt64, false),
1010 ],
1011 ),
1012 (
1013 &["Bar"],
1014 &[
1015 ("open", LegacyFieldType::Fixed, false),
1016 ("high", LegacyFieldType::Fixed, false),
1017 ("low", LegacyFieldType::Fixed, false),
1018 ("close", LegacyFieldType::Fixed, false),
1019 ("volume", LegacyFieldType::Fixed, false),
1020 ("ts_event", LegacyFieldType::UInt64, false),
1021 ("ts_init", LegacyFieldType::UInt64, false),
1022 ],
1023 ),
1024 (
1025 &["MarkPriceUpdate", "IndexPriceUpdate"],
1026 &[
1027 ("value", LegacyFieldType::Fixed, false),
1028 ("ts_event", LegacyFieldType::UInt64, false),
1029 ("ts_init", LegacyFieldType::UInt64, false),
1030 ],
1031 ),
1032 (
1033 &["FundingRateUpdate"],
1034 &[
1035 ("rate", LegacyFieldType::Binary, false),
1036 ("interval", LegacyFieldType::UInt16, true),
1037 ("next_funding_ns", LegacyFieldType::UInt64, true),
1038 ("ts_event", LegacyFieldType::UInt64, false),
1039 ("ts_init", LegacyFieldType::UInt64, false),
1040 ],
1041 ),
1042 (
1043 &["InstrumentStatus"],
1044 &[
1045 ("instrument_id", LegacyFieldType::Utf8, true),
1046 ("action", LegacyFieldType::Utf8, true),
1047 ("reason", LegacyFieldType::Utf8, true),
1048 ("trading_event", LegacyFieldType::Utf8, true),
1049 ("is_trading", LegacyFieldType::Boolean, true),
1050 ("is_quoting", LegacyFieldType::Boolean, true),
1051 ("is_short_sell_restricted", LegacyFieldType::Boolean, true),
1052 ("ts_event", LegacyFieldType::UInt64, true),
1053 ("ts_init", LegacyFieldType::UInt64, true),
1054 ],
1055 ),
1056 (
1057 &["OptionGreeks"],
1058 &[
1059 ("instrument_id", LegacyFieldType::Utf8, false),
1060 ("delta", LegacyFieldType::Float64, false),
1061 ("gamma", LegacyFieldType::Float64, false),
1062 ("vega", LegacyFieldType::Float64, false),
1063 ("theta", LegacyFieldType::Float64, false),
1064 ("rho", LegacyFieldType::Float64, false),
1065 ("mark_iv", LegacyFieldType::Float64, true),
1066 ("bid_iv", LegacyFieldType::Float64, true),
1067 ("ask_iv", LegacyFieldType::Float64, true),
1068 ("underlying_price", LegacyFieldType::Float64, true),
1069 ("open_interest", LegacyFieldType::Float64, true),
1070 ("ts_event", LegacyFieldType::UInt64, false),
1071 ("ts_init", LegacyFieldType::UInt64, false),
1072 ("convention", LegacyFieldType::Utf8, false),
1073 ],
1074 ),
1075];
1076
1077#[must_use]
1079pub fn is_nautilus_legacy_schema(schema: &Schema) -> bool {
1080 if let Some(type_name) = schema
1081 .metadata()
1082 .get("type_name")
1083 .or_else(|| schema.metadata().get("type"))
1084 {
1085 return legacy_metadata_family_matches(schema, type_name);
1086 }
1087
1088 LEGACY_SCHEMA_FAMILIES
1089 .iter()
1090 .any(|(_, fields)| schema_fingerprint_matches(schema, fields))
1091 || legacy_flat_depth_fingerprint_matches(schema)
1092 || legacy_fixed_list_depth_fingerprint_matches(schema)
1093}
1094
1095#[must_use]
1097pub fn is_nautilus_timestamp_schema(schema: &Schema) -> bool {
1098 if schema.metadata().contains_key("type_name") || schema.metadata().contains_key("type") {
1099 return true;
1100 }
1101
1102 if is_nautilus_legacy_schema(schema) {
1103 return true;
1104 }
1105 let fields = schema
1106 .fields()
1107 .iter()
1108 .filter(|field| field.name() != KEY_IDENTIFIER)
1109 .collect::<Vec<_>>();
1110 LEGACY_SCHEMA_FAMILIES.iter().any(|(_, expected)| {
1111 fields.len() == expected.len()
1112 && fields
1113 .iter()
1114 .zip(*expected)
1115 .all(|(field, (name, expected, _))| {
1116 field.name() == *name
1117 && (legacy_field_type_matches(field.data_type(), *expected)
1118 || (is_timestamp_field(name)
1119 && matches!(
1120 field.data_type(),
1121 DataType::Timestamp(TimeUnit::Nanosecond, _)
1122 ))
1123 || (matches!(expected, LegacyFieldType::Fixed)
1124 && field.data_type() == &fixed_decimal_data_type())
1125 || (is_legacy_enum_field(name)
1126 && field.data_type() == &enum_dictionary_data_type()))
1127 })
1128 })
1129}
1130
1131#[must_use]
1133pub fn normalized_timestamp_type(data_type: &DataType) -> DataType {
1134 match data_type {
1135 DataType::Timestamp(TimeUnit::Nanosecond, None) => timestamp_data_type(),
1136 _ => data_type.clone(),
1137 }
1138}
1139
1140fn legacy_metadata_family_matches(schema: &Schema, type_name: &str) -> bool {
1141 match type_name {
1142 "OrderBookDepth10" | "OrderBookDepth" => {
1143 return legacy_flat_depth_fingerprint_matches(schema)
1144 || legacy_fixed_list_depth_fingerprint_matches(schema);
1145 }
1146 _ => {}
1147 }
1148
1149 LEGACY_SCHEMA_FAMILIES
1150 .iter()
1151 .filter(|(names, _)| names.contains(&type_name))
1152 .any(|(_, fields)| schema_fingerprint_matches(schema, fields))
1153}
1154
1155#[derive(Clone, Copy)]
1156enum LegacyFieldType {
1157 Binary,
1158 Float64,
1159 Fixed,
1160 UInt8,
1161 UInt16,
1162 UInt32,
1163 UInt64,
1164 Utf8,
1165 Boolean,
1166}
1167
1168fn schema_fingerprint_matches(schema: &Schema, expected: &[(&str, LegacyFieldType, bool)]) -> bool {
1169 let fields = schema
1170 .fields()
1171 .iter()
1172 .filter(|field| field.name() != KEY_IDENTIFIER)
1173 .collect::<Vec<_>>();
1174
1175 let fields_match = fields.len() == expected.len()
1176 && fields
1177 .iter()
1178 .zip(expected)
1179 .all(|(field, (name, data_type, _))| {
1180 field.name() == *name && legacy_field_type_matches(field.data_type(), *data_type)
1181 });
1182 let nullability_matches = fields
1183 .iter()
1184 .zip(expected)
1185 .all(|(field, (_, _, nullable))| field.is_nullable() == *nullable)
1186 || fields.iter().all(|field| field.is_nullable());
1187
1188 fields_match
1189 && nullability_matches
1190 && fixed_width_is_consistent(fields.iter().map(|field| field.data_type()))
1191}
1192
1193fn legacy_field_type_matches(data_type: &DataType, expected: LegacyFieldType) -> bool {
1194 match expected {
1195 LegacyFieldType::Binary => data_type == &DataType::Binary,
1196 LegacyFieldType::Float64 => data_type == &DataType::Float64,
1197 LegacyFieldType::Fixed => matches!(data_type, DataType::FixedSizeBinary(8 | 16)),
1198 LegacyFieldType::UInt8 => data_type == &DataType::UInt8,
1199 LegacyFieldType::UInt16 => data_type == &DataType::UInt16,
1200 LegacyFieldType::UInt32 => data_type == &DataType::UInt32,
1201 LegacyFieldType::UInt64 => data_type == &DataType::UInt64,
1202 LegacyFieldType::Utf8 => match data_type {
1203 DataType::Utf8 | DataType::Utf8View => true,
1204 DataType::Dictionary(_, value) => value.as_ref() == &DataType::Utf8,
1205 _ => false,
1206 },
1207 LegacyFieldType::Boolean => data_type == &DataType::Boolean,
1208 }
1209}
1210
1211fn fixed_width_is_consistent<'a>(data_types: impl Iterator<Item = &'a DataType>) -> bool {
1212 let mut width = None;
1213 data_types
1214 .filter_map(|data_type| match data_type {
1215 DataType::FixedSizeBinary(width) => Some(*width),
1216 _ => None,
1217 })
1218 .all(|current| *width.get_or_insert(current) == current)
1219}
1220
1221fn legacy_flat_depth_fingerprint_matches(schema: &Schema) -> bool {
1222 let fields = schema
1223 .fields()
1224 .iter()
1225 .filter(|field| field.name() != KEY_IDENTIFIER)
1226 .collect::<Vec<_>>();
1227 let market_fields = fields
1228 .iter()
1229 .filter(|field| {
1230 matches!(
1231 field.name().as_str(),
1232 "flags" | "sequence" | "ts_event" | "ts_init"
1233 )
1234 })
1235 .count();
1236
1237 let order_id_fields = fields
1238 .iter()
1239 .filter(|field| {
1240 field.name().starts_with("bid_order_id_") || field.name().starts_with("ask_order_id_")
1241 })
1242 .count();
1243 let all_nullable = fields.iter().all(|field| field.is_nullable());
1244
1245 fields.len() == 6 * DEPTH10_LEN + order_id_fields + market_fields
1246 && market_fields <= 4
1247 && matches!(order_id_fields, 0 | 20)
1248 && fixed_width_is_consistent(fields.iter().map(|field| field.data_type()))
1249 && ["bid", "ask"].iter().all(|side| {
1250 (0..DEPTH10_LEN).all(|level| {
1251 [
1252 ("price", LegacyFieldType::Fixed),
1253 ("size", LegacyFieldType::Fixed),
1254 ("count", LegacyFieldType::UInt32),
1255 ]
1256 .iter()
1257 .all(|(value, data_type)| {
1258 schema
1259 .field_with_name(&format!("{side}_{value}_{level}"))
1260 .is_ok_and(|field| {
1261 (all_nullable || !field.is_nullable())
1262 && legacy_field_type_matches(field.data_type(), *data_type)
1263 })
1264 })
1265 })
1266 })
1267 && (order_id_fields == 0
1268 || ["bid", "ask"].iter().all(|side| {
1269 (0..DEPTH10_LEN).all(|level| {
1270 schema
1271 .field_with_name(&format!("{side}_order_id_{level}"))
1272 .is_ok_and(|field| {
1273 (all_nullable || !field.is_nullable())
1274 && legacy_field_type_matches(
1275 field.data_type(),
1276 LegacyFieldType::UInt64,
1277 )
1278 })
1279 })
1280 }))
1281 && [
1282 ("flags", LegacyFieldType::UInt8),
1283 ("sequence", LegacyFieldType::UInt64),
1284 ("ts_event", LegacyFieldType::UInt64),
1285 ("ts_init", LegacyFieldType::UInt64),
1286 ]
1287 .iter()
1288 .all(|(name, data_type)| {
1289 let Ok(field) = schema.field_with_name(name) else {
1290 return true;
1291 };
1292 (all_nullable || !field.is_nullable())
1293 && legacy_field_type_matches(field.data_type(), *data_type)
1294 })
1295}
1296
1297fn legacy_fixed_list_depth_fingerprint_matches(schema: &Schema) -> bool {
1298 let fixed_list_matches = |name: &str, expected: &DataType| {
1299 schema.field_with_name(name).is_ok_and(|field| {
1300 matches!(
1301 field.data_type(),
1302 DataType::FixedSizeList(item, length)
1303 if *length == i32::try_from(DEPTH10_LEN).expect("depth length fits i32")
1304 && item.data_type() == expected
1305 )
1306 })
1307 };
1308 let widths = ["bid_price", "ask_price", "bid_size", "ask_size"]
1309 .iter()
1310 .filter_map(|name| {
1311 let field = schema.field_with_name(name).ok()?;
1312 let DataType::FixedSizeList(item, _) = field.data_type() else {
1313 return None;
1314 };
1315 let DataType::FixedSizeBinary(width @ (8 | 16)) = item.data_type() else {
1316 return None;
1317 };
1318 Some(*width)
1319 })
1320 .collect::<Vec<_>>();
1321 let order_id_fields = ["bid_order_id", "ask_order_id"]
1322 .iter()
1323 .filter(|name| schema.field_with_name(name).is_ok())
1324 .count();
1325
1326 widths.len() == 4
1327 && widths.iter().all(|width| *width == widths[0])
1328 && ["bid_count", "ask_count"]
1329 .iter()
1330 .all(|name| fixed_list_matches(name, &DataType::UInt32))
1331 && matches!(order_id_fields, 0 | 2)
1332 && (order_id_fields == 0
1333 || ["bid_order_id", "ask_order_id"]
1334 .iter()
1335 .all(|name| fixed_list_matches(name, &DataType::UInt64)))
1336}
1337
1338#[must_use]
1340pub fn is_timestamp_field(name: &str) -> bool {
1341 name.starts_with("ts_")
1342 || matches!(
1343 name,
1344 "activation_ns"
1345 | "expiration_ns"
1346 | "next_funding_ns"
1347 | "expire_time"
1348 | "event_open_date"
1349 | "market_start_time"
1350 )
1351}
1352
1353#[must_use]
1355pub fn is_legacy_enum_field(name: &str) -> bool {
1356 matches!(name, "action" | "side" | "aggressor_side" | "close_type")
1357}
1358
1359fn legacy_enum_name(name: &str, value: u8) -> Result<String, ArrowError> {
1360 let name_value = match name {
1361 "action" => BookAction::from_u8(value).map(|value| value.to_string()),
1362 "side" => match value {
1363 0 => Some("NO_ORDER_SIDE".to_string()),
1364 1 => Some(OrderSide::Buy.to_string()),
1365 2 => Some(OrderSide::Sell.to_string()),
1366 _ => None,
1367 },
1368 "aggressor_side" => AggressorSide::from_u8(value).map(|value| value.to_string()),
1369 "close_type" => InstrumentCloseType::from_u8(value).map(|value| value.to_string()),
1370 _ => None,
1371 };
1372 name_value.ok_or_else(|| {
1373 ArrowError::CastError(format!(
1374 "Invalid legacy enum value {value} for column '{name}'"
1375 ))
1376 })
1377}
1378
1379const LEGACY_QUANTITY_FIELDS: &[&str] = &[
1381 "size",
1382 "quantity",
1383 "qty",
1384 "volume",
1385 "bid_size",
1386 "ask_size",
1387 "bid_size_0",
1388 "bid_size_1",
1389 "bid_size_2",
1390 "bid_size_3",
1391 "bid_size_4",
1392 "bid_size_5",
1393 "bid_size_6",
1394 "bid_size_7",
1395 "bid_size_8",
1396 "bid_size_9",
1397 "ask_size_0",
1398 "ask_size_1",
1399 "ask_size_2",
1400 "ask_size_3",
1401 "ask_size_4",
1402 "ask_size_5",
1403 "ask_size_6",
1404 "ask_size_7",
1405 "ask_size_8",
1406 "ask_size_9",
1407];
1408
1409fn is_legacy_quantity_field(name: &str) -> bool {
1410 LEGACY_QUANTITY_FIELDS.contains(&name)
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415 use std::collections::HashMap;
1416
1417 use arrow::{
1418 array::{BinaryArray, BooleanArray},
1419 datatypes::Schema,
1420 };
1421 use nautilus_model::{
1422 data::{FundingRateUpdate, InstrumentClose},
1423 enums::{InstrumentCloseType, MarketStatusAction},
1424 };
1425 use rstest::rstest;
1426
1427 use super::*;
1428 use crate::arrow::{DecodeFromRecordBatch, DecodeTypedFromRecordBatch, StringColumnRef};
1429
1430 #[rstest]
1431 #[case("size", true)]
1432 #[case("volume", true)]
1433 #[case("bid_size_0", true)]
1434 #[case("ask_size_9", true)]
1435 #[case("price", false)]
1436 #[case("quantity_hint", false)]
1437 #[case("bid_size_10", false)]
1438 fn legacy_quantity_fields_use_exact_schema_names(#[case] name: &str, #[case] expected: bool) {
1439 assert_eq!(is_legacy_quantity_field(name), expected);
1440 }
1441
1442 #[rstest]
1443 fn legacy_market_columns_normalize_enums_without_changing_flags() {
1444 let price = 1_i64.to_le_bytes();
1445 let size = 2_u64.to_le_bytes();
1446 let batch = RecordBatch::try_new(
1447 Arc::new(Schema::new(vec![
1448 Field::new("action", DataType::UInt8, false),
1449 Field::new("side", DataType::UInt8, false),
1450 Field::new("price", DataType::FixedSizeBinary(8), false),
1451 Field::new("size", DataType::FixedSizeBinary(8), false),
1452 Field::new("order_id", DataType::UInt64, false),
1453 Field::new("flags", DataType::UInt8, false),
1454 Field::new("sequence", DataType::UInt64, false),
1455 Field::new("ts_event", DataType::UInt64, false),
1456 Field::new("ts_init", DataType::UInt64, false),
1457 ])),
1458 vec![
1459 Arc::new(UInt8Array::from(vec![BookAction::Add as u8])),
1460 Arc::new(UInt8Array::from(vec![OrderSide::Buy as u8])),
1461 Arc::new(
1462 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1463 [Some(price.as_slice())].into_iter(),
1464 8,
1465 )
1466 .unwrap(),
1467 ),
1468 Arc::new(
1469 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1470 [Some(size.as_slice())].into_iter(),
1471 8,
1472 )
1473 .unwrap(),
1474 ),
1475 Arc::new(UInt64Array::from(vec![11])),
1476 Arc::new(UInt8Array::from(vec![7])),
1477 Arc::new(UInt64Array::from(vec![12])),
1478 Arc::new(UInt64Array::from(vec![13])),
1479 Arc::new(UInt64Array::from(vec![1])),
1480 ],
1481 )
1482 .unwrap();
1483
1484 let normalized = normalize_legacy_fixed_columns(&batch).unwrap();
1485 let action =
1486 StringColumnRef::try_from_array(normalized.column_by_name("action").unwrap().as_ref())
1487 .unwrap();
1488 let side =
1489 StringColumnRef::try_from_array(normalized.column_by_name("side").unwrap().as_ref())
1490 .unwrap();
1491
1492 assert_eq!(action.value(0), "ADD");
1493 assert_eq!(side.value(0), "BUY");
1494 assert_eq!(
1495 normalized.column_by_name("price").unwrap().data_type(),
1496 &fixed_decimal_data_type(),
1497 );
1498 assert_eq!(
1499 normalized.column_by_name("size").unwrap().data_type(),
1500 &fixed_decimal_data_type(),
1501 );
1502 assert_eq!(
1503 normalized.column_by_name("flags").unwrap().data_type(),
1504 &DataType::UInt8,
1505 );
1506 assert_eq!(
1507 normalized.column_by_name("ts_init").unwrap().data_type(),
1508 ×tamp_data_type(),
1509 );
1510 }
1511
1512 #[rstest]
1513 fn unrelated_named_columns_are_not_retyped() {
1514 let batch = RecordBatch::try_new(
1515 Arc::new(Schema::new(vec![
1516 Field::new("side", DataType::UInt8, false),
1517 Field::new("ts_recv", DataType::UInt64, false),
1518 ])),
1519 vec![
1520 Arc::new(UInt8Array::from(vec![127])),
1521 Arc::new(UInt64Array::from(vec![u64::MAX])),
1522 ],
1523 )
1524 .unwrap();
1525
1526 let normalized = normalize_legacy_fixed_columns(&batch).unwrap();
1527
1528 assert_eq!(normalized, batch);
1529 }
1530
1531 #[rstest]
1532 fn unrelated_fixed_binary_column_is_not_retyped() {
1533 let value = i64::MIN.to_le_bytes();
1534 let batch = RecordBatch::try_new(
1535 Arc::new(Schema::new(vec![Field::new(
1536 "price",
1537 DataType::FixedSizeBinary(8),
1538 false,
1539 )])),
1540 vec![Arc::new(
1541 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1542 [Some(value.as_slice())].into_iter(),
1543 8,
1544 )
1545 .unwrap(),
1546 )],
1547 )
1548 .unwrap();
1549
1550 let normalized = normalize_legacy_fixed_columns(&batch).unwrap();
1551
1552 assert_eq!(normalized, batch);
1553 }
1554
1555 #[rstest]
1556 fn legacy_family_fingerprints_cover_unpinned_schemas() {
1557 let fixed = DataType::FixedSizeBinary(8);
1558 let schemas = [
1559 Schema::new(vec![
1560 Field::new("close_price", fixed.clone(), false),
1561 Field::new("close_type", DataType::UInt8, false),
1562 Field::new("ts_event", DataType::UInt64, false),
1563 Field::new("ts_init", DataType::UInt64, false),
1564 ]),
1565 Schema::new(vec![
1566 Field::new("instrument_id", DataType::Utf8, true),
1567 Field::new("close_type", DataType::Utf8, true),
1568 Field::new("close_price", DataType::Utf8, true),
1569 Field::new("ts_event", DataType::UInt64, true),
1570 Field::new("ts_init", DataType::UInt64, true),
1571 ]),
1572 Schema::new(vec![
1573 Field::new("value", fixed, false),
1574 Field::new("ts_event", DataType::UInt64, false),
1575 Field::new("ts_init", DataType::UInt64, false),
1576 ]),
1577 Schema::new(vec![
1578 Field::new("rate", DataType::Binary, false),
1579 Field::new("interval", DataType::UInt16, true),
1580 Field::new("next_funding_ns", DataType::UInt64, true),
1581 Field::new("ts_event", DataType::UInt64, false),
1582 Field::new("ts_init", DataType::UInt64, false),
1583 ]),
1584 Schema::new(vec![
1585 Field::new("instrument_id", DataType::Utf8, true),
1586 Field::new("action", DataType::Utf8, true),
1587 Field::new("reason", DataType::Utf8, true),
1588 Field::new("trading_event", DataType::Utf8, true),
1589 Field::new("is_trading", DataType::Boolean, true),
1590 Field::new("is_quoting", DataType::Boolean, true),
1591 Field::new("is_short_sell_restricted", DataType::Boolean, true),
1592 Field::new("ts_event", DataType::UInt64, true),
1593 Field::new("ts_init", DataType::UInt64, true),
1594 ]),
1595 ];
1596
1597 assert!(schemas.iter().all(is_nautilus_legacy_schema));
1598 }
1599
1600 #[rstest]
1601 fn legacy_family_fingerprint_accepts_dictionary_string_fields() {
1602 let schema = Schema::new(vec![
1603 Field::new("price", DataType::FixedSizeBinary(8), false),
1604 Field::new("size", DataType::FixedSizeBinary(8), false),
1605 Field::new("aggressor_side", DataType::UInt8, false),
1606 Field::new(
1607 "trade_id",
1608 DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8)),
1609 false,
1610 ),
1611 Field::new("ts_event", DataType::UInt64, false),
1612 Field::new("ts_init", DataType::UInt64, false),
1613 Field::new(
1614 KEY_IDENTIFIER,
1615 DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8)),
1616 false,
1617 ),
1618 ]);
1619
1620 assert!(is_nautilus_legacy_schema(&schema));
1621 }
1622
1623 #[rstest]
1624 fn legacy_flat_depth_fingerprint_accepts_all_nullable_fields() {
1625 let fixed = DataType::FixedSizeBinary(8);
1626 let mut fields = Vec::new();
1627
1628 for side in ["bid", "ask"] {
1629 for level in 0..DEPTH10_LEN {
1630 fields.extend([
1631 Field::new(format!("{side}_price_{level}"), fixed.clone(), true),
1632 Field::new(format!("{side}_size_{level}"), fixed.clone(), true),
1633 Field::new(format!("{side}_count_{level}"), DataType::UInt32, true),
1634 ]);
1635 }
1636 }
1637 fields.extend([
1638 Field::new("flags", DataType::UInt8, true),
1639 Field::new("sequence", DataType::UInt64, true),
1640 Field::new("ts_event", DataType::UInt64, true),
1641 Field::new("ts_init", DataType::UInt64, true),
1642 ]);
1643 let schema = Schema::new(fields);
1644
1645 assert!(super::legacy_flat_depth_fingerprint_matches(&schema));
1646 }
1647
1648 #[rstest]
1649 fn legacy_fixed_list_depth_fingerprint_accepts_missing_order_ids() {
1650 let fixed = Arc::new(Field::new("item", DataType::FixedSizeBinary(8), false));
1651 let count = Arc::new(Field::new("item", DataType::UInt32, false));
1652 let schema = Schema::new(vec![
1653 Field::new(
1654 "bid_price",
1655 DataType::FixedSizeList(fixed.clone(), 10),
1656 false,
1657 ),
1658 Field::new(
1659 "ask_price",
1660 DataType::FixedSizeList(fixed.clone(), 10),
1661 false,
1662 ),
1663 Field::new(
1664 "bid_size",
1665 DataType::FixedSizeList(fixed.clone(), 10),
1666 false,
1667 ),
1668 Field::new("ask_size", DataType::FixedSizeList(fixed, 10), false),
1669 Field::new(
1670 "bid_count",
1671 DataType::FixedSizeList(count.clone(), 10),
1672 false,
1673 ),
1674 Field::new("ask_count", DataType::FixedSizeList(count, 10), false),
1675 ]);
1676
1677 assert!(is_nautilus_legacy_schema(&schema));
1678 }
1679
1680 #[rstest]
1681 fn metadata_only_schema_is_not_a_legacy_family() {
1682 let schema = Schema::new_with_metadata(
1683 vec![
1684 Field::new("side", DataType::UInt8, false),
1685 Field::new("ts_recv", DataType::UInt64, false),
1686 ],
1687 [("type_name".to_string(), "CustomData".to_string())].into(),
1688 );
1689
1690 assert!(!is_nautilus_legacy_schema(&schema));
1691 }
1692
1693 #[rstest]
1694 fn known_metadata_families_require_and_normalize_their_legacy_shape() {
1695 let schemas = [
1696 Schema::new_with_metadata(
1697 vec![
1698 Field::new("rate", DataType::Binary, false),
1699 Field::new("interval", DataType::UInt16, true),
1700 Field::new("next_funding_ns", DataType::UInt64, true),
1701 Field::new("ts_event", DataType::UInt64, false),
1702 Field::new("ts_init", DataType::UInt64, false),
1703 ],
1704 [("type".to_string(), "FundingRateUpdate".to_string())].into(),
1705 ),
1706 Schema::new_with_metadata(
1707 vec![
1708 Field::new("instrument_id", DataType::Utf8, true),
1709 Field::new("action", DataType::Utf8, true),
1710 Field::new("reason", DataType::Utf8, true),
1711 Field::new("trading_event", DataType::Utf8, true),
1712 Field::new("is_trading", DataType::Boolean, true),
1713 Field::new("is_quoting", DataType::Boolean, true),
1714 Field::new("is_short_sell_restricted", DataType::Boolean, true),
1715 Field::new("ts_event", DataType::UInt64, true),
1716 Field::new("ts_init", DataType::UInt64, true),
1717 ],
1718 [("type".to_string(), "InstrumentStatus".to_string())].into(),
1719 ),
1720 Schema::new_with_metadata(
1721 vec![
1722 Field::new("instrument_id", DataType::Utf8, false),
1723 Field::new("delta", DataType::Float64, false),
1724 Field::new("gamma", DataType::Float64, false),
1725 Field::new("vega", DataType::Float64, false),
1726 Field::new("theta", DataType::Float64, false),
1727 Field::new("rho", DataType::Float64, false),
1728 Field::new("mark_iv", DataType::Float64, true),
1729 Field::new("bid_iv", DataType::Float64, true),
1730 Field::new("ask_iv", DataType::Float64, true),
1731 Field::new("underlying_price", DataType::Float64, true),
1732 Field::new("open_interest", DataType::Float64, true),
1733 Field::new("ts_event", DataType::UInt64, false),
1734 Field::new("ts_init", DataType::UInt64, false),
1735 Field::new("convention", DataType::Utf8, false),
1736 ],
1737 [("type".to_string(), "OptionGreeks".to_string())].into(),
1738 ),
1739 ];
1740
1741 for schema in schemas {
1742 assert!(is_nautilus_legacy_schema(&schema));
1743 let normalized =
1744 normalize_legacy_fixed_columns(&RecordBatch::new_empty(Arc::new(schema))).unwrap();
1745
1746 assert_eq!(
1747 normalized
1748 .schema()
1749 .field_with_name("ts_init")
1750 .unwrap()
1751 .data_type(),
1752 ×tamp_data_type(),
1753 );
1754 }
1755 }
1756
1757 #[rstest]
1758 fn near_match_with_foreign_field_type_is_not_retyped() {
1759 let schema = Arc::new(Schema::new(vec![
1760 Field::new("action", DataType::UInt8, false),
1761 Field::new("side", DataType::UInt8, false),
1762 Field::new("price", DataType::FixedSizeBinary(8), false),
1763 Field::new("size", DataType::FixedSizeBinary(8), false),
1764 Field::new("order_id", DataType::UInt64, false),
1765 Field::new("flags", DataType::UInt8, false),
1766 Field::new("sequence", DataType::Int64, false),
1767 Field::new("ts_event", DataType::UInt64, false),
1768 Field::new("ts_init", DataType::UInt64, false),
1769 ]));
1770 let batch = RecordBatch::new_empty(schema);
1771
1772 let normalized = normalize_legacy_fixed_columns(&batch).unwrap();
1773
1774 assert_eq!(normalized, batch);
1775 }
1776
1777 #[rstest]
1778 fn legacy_price_error_fails_with_field_and_row() {
1779 let fixed = |raw: [u8; 8]| -> ArrayRef {
1780 Arc::new(
1781 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1782 [Some(raw.as_slice())].into_iter(),
1783 8,
1784 )
1785 .unwrap(),
1786 )
1787 };
1788 let batch = RecordBatch::try_new(
1789 Arc::new(Schema::new_with_metadata(
1790 vec![
1791 Field::new("bid_price", DataType::FixedSizeBinary(8), false),
1792 Field::new("ask_price", DataType::FixedSizeBinary(8), false),
1793 Field::new("bid_size", DataType::FixedSizeBinary(8), false),
1794 Field::new("ask_size", DataType::FixedSizeBinary(8), false),
1795 Field::new("ts_event", DataType::UInt64, false),
1796 Field::new("ts_init", DataType::UInt64, false),
1797 ],
1798 [("type".to_string(), "QuoteTick".to_string())].into(),
1799 )),
1800 vec![
1801 fixed(i64::MIN.to_le_bytes()),
1802 fixed(2_i64.to_le_bytes()),
1803 fixed(3_u64.to_le_bytes()),
1804 fixed(4_u64.to_le_bytes()),
1805 Arc::new(UInt64Array::from(vec![5])),
1806 Arc::new(UInt64Array::from(vec![6])),
1807 ],
1808 )
1809 .unwrap();
1810
1811 let error = normalize_legacy_fixed_columns(&batch).unwrap_err();
1812
1813 assert_eq!(
1814 error.to_string(),
1815 format!(
1816 "Cast error: Legacy price column 'bid_price' contains PRICE_ERROR raw value {} at row 0",
1817 i64::MIN,
1818 ),
1819 );
1820 }
1821
1822 #[rstest]
1823 fn metadata_family_near_match_passes_through_unchanged() {
1824 let raw = i64::MIN.to_le_bytes();
1825 let batch = RecordBatch::try_new(
1826 Arc::new(Schema::new_with_metadata(
1827 vec![Field::new("bid_price", DataType::FixedSizeBinary(8), false)],
1828 [("type".to_string(), "QuoteTick".to_string())].into(),
1829 )),
1830 vec![Arc::new(
1831 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
1832 [Some(raw.as_slice())].into_iter(),
1833 8,
1834 )
1835 .unwrap(),
1836 )],
1837 )
1838 .unwrap();
1839
1840 let normalized = normalize_legacy_fixed_columns(&batch).unwrap();
1841
1842 assert_eq!(normalized, batch);
1843 }
1844
1845 #[rstest]
1846 fn schema_fingerprint_ignores_metadata_and_includes_nullability() {
1847 let fields = vec![Field::new("value", DataType::Utf8, false)];
1848 let schema1 = Schema::new_with_metadata(
1849 fields.clone(),
1850 HashMap::from([("a".to_string(), "1".to_string())]),
1851 );
1852 let schema2 =
1853 Schema::new_with_metadata(fields, HashMap::from([("b".to_string(), "2".to_string())]));
1854 let nullable = Schema::new(vec![Field::new("value", DataType::Utf8, true)]);
1855
1856 assert_eq!(schema_fingerprint(&schema1), schema_fingerprint(&schema2));
1857 assert_ne!(schema_fingerprint(&schema1), schema_fingerprint(&nullable));
1858 }
1859
1860 #[rstest]
1861 fn current_schema_passes_through() {
1862 let batch = RecordBatch::new_empty(Arc::new(QuoteTick::get_schema(None)));
1863 let result = transcode_legacy_record_batch("quotes", "quotes.parquet", batch).unwrap();
1864
1865 assert_eq!(result.kind, LegacyTranscodeKind::PassThrough);
1866 assert_eq!(result.batches.len(), 1);
1867 }
1868
1869 #[rstest]
1870 fn parquet_schema_with_plain_dictionary_strings_passes_through() {
1871 let schema = schema_without_identifier_column(&schema_with_plain_dictionary_strings(
1872 &TradeTick::get_schema(None),
1873 ));
1874 let batch = RecordBatch::new_empty(Arc::new(schema));
1875 let result = transcode_legacy_record_batch("trades", "trades.parquet", batch).unwrap();
1876
1877 assert_eq!(result.kind, LegacyTranscodeKind::PassThrough);
1878 assert_eq!(result.batches.len(), 1);
1879 }
1880
1881 #[rstest]
1882 fn unknown_registered_schema_is_rejected() {
1883 let batch = RecordBatch::new_empty(Arc::new(Schema::new(vec![Field::new(
1884 "unexpected",
1885 DataType::UInt64,
1886 false,
1887 )])));
1888 let error = transcode_legacy_record_batch("quotes", "quotes.parquet", batch).unwrap_err();
1889
1890 assert!(matches!(error, LegacyArrowError::UnknownSchema { .. }));
1891 assert!(error.to_string().contains("quotes.parquet"));
1892 }
1893
1894 #[rstest]
1895 fn instrument_status_fields_are_reordered() {
1896 let schema = Arc::new(legacy_instrument_status_schema());
1897 let batch = RecordBatch::try_new(
1898 schema,
1899 vec![
1900 Arc::new(StringArray::from(vec!["AAPL.XNAS"])),
1901 Arc::new(StringArray::from(vec!["TRADING"])),
1902 Arc::new(StringArray::from(vec![Some("Normal")])),
1903 Arc::new(StringArray::from(vec![Some("OPEN")])),
1904 Arc::new(BooleanArray::from(vec![Some(true)])),
1905 Arc::new(BooleanArray::from(vec![Some(true)])),
1906 Arc::new(BooleanArray::from(vec![Some(false)])),
1907 Arc::new(UInt64Array::from(vec![1])),
1908 Arc::new(UInt64Array::from(vec![2])),
1909 ],
1910 )
1911 .unwrap();
1912
1913 let result =
1914 transcode_legacy_record_batch("instrument_status", "status.parquet", batch).unwrap();
1915 let output = &result.batches[0];
1916
1917 assert_eq!(result.kind, LegacyTranscodeKind::InstrumentStatusV1);
1918 assert_eq!(
1919 output
1920 .schema()
1921 .fields()
1922 .iter()
1923 .map(|field| field.name().as_str())
1924 .collect::<Vec<_>>(),
1925 vec![
1926 "instrument_id",
1927 "action",
1928 "ts_event",
1929 "ts_init",
1930 "reason",
1931 "trading_event",
1932 "is_trading",
1933 "is_quoting",
1934 "is_short_sell_restricted",
1935 "identifier",
1936 ]
1937 );
1938 let decoded =
1939 InstrumentStatus::decode_typed_batch(output.schema().metadata(), output.clone())
1940 .unwrap();
1941 assert_eq!(decoded[0].action, MarketStatusAction::Trading);
1942 assert_eq!(decoded[0].instrument_id.to_string(), "AAPL.XNAS");
1943 assert_eq!(decoded[0].ts_event.as_u64(), 1);
1944 assert_eq!(decoded[0].ts_init.as_u64(), 2);
1945 }
1946
1947 #[rstest]
1948 fn funding_rate_is_decoded_and_interval_is_widened() {
1949 let schema = Arc::new(Schema::new_with_metadata(
1950 legacy_funding_rate_schema()
1951 .fields()
1952 .iter()
1953 .cloned()
1954 .collect::<Vec<_>>(),
1955 HashMap::from([(
1956 KEY_INSTRUMENT_ID.to_string(),
1957 "BTCUSDT-PERP.BINANCE".to_string(),
1958 )]),
1959 ));
1960 let batch = RecordBatch::try_new(
1961 schema,
1962 vec![
1963 Arc::new(BinaryArray::from(vec![b"\"0.0001\"".as_slice()])),
1964 Arc::new(UInt16Array::from(vec![Some(480)])),
1965 Arc::new(UInt64Array::from(vec![Some(9)])),
1966 Arc::new(UInt64Array::from(vec![1])),
1967 Arc::new(UInt64Array::from(vec![2])),
1968 ],
1969 )
1970 .unwrap();
1971
1972 let result =
1973 transcode_legacy_record_batch("funding_rates", "funding.parquet", batch).unwrap();
1974 let output = &result.batches[0];
1975 let decoded =
1976 FundingRateUpdate::decode_typed_batch(output.schema().metadata(), output.clone())
1977 .unwrap();
1978
1979 assert_eq!(result.kind, LegacyTranscodeKind::FundingRateUpdateV1);
1980 assert_eq!(decoded[0].instrument_id.to_string(), "BTCUSDT-PERP.BINANCE");
1981 assert_eq!(decoded[0].rate.to_string(), "0.0001");
1982 assert_eq!(decoded[0].interval, Some(480));
1983 assert_eq!(
1984 decoded[0].next_funding_ns.map(|value| value.as_u64()),
1985 Some(9)
1986 );
1987 }
1988
1989 #[rstest]
1990 #[case(false)]
1991 #[case(true)]
1992 fn instrument_close_is_split_and_decoded(#[case] normalized: bool) {
1993 let schema = Arc::new(legacy_instrument_close_schema());
1994 let batch = RecordBatch::try_new(
1995 schema,
1996 vec![
1997 Arc::new(StringArray::from(vec![
1998 "AUD/USD.SIM",
1999 "GBP/USD.SIM",
2000 "AUD/USD.SIM",
2001 ])),
2002 Arc::new(StringArray::from(vec![
2003 "END_OF_SESSION",
2004 "CONTRACT_EXPIRED",
2005 "END_OF_SESSION",
2006 ])),
2007 Arc::new(StringArray::from(vec!["1.0500", "2.1000", "1.0600"])),
2008 Arc::new(UInt64Array::from(vec![1, 2, 3])),
2009 Arc::new(UInt64Array::from(vec![4, 5, 6])),
2010 ],
2011 )
2012 .unwrap();
2013
2014 let batch = if normalized {
2015 normalize_legacy_fixed_columns(&batch).unwrap()
2016 } else {
2017 batch
2018 };
2019
2020 let result =
2021 transcode_legacy_record_batch("instrument_closes", "close.parquet", batch).unwrap();
2022 let decoded = result
2023 .batches
2024 .iter()
2025 .flat_map(|batch| {
2026 InstrumentClose::decode_batch(batch.schema().metadata(), batch.clone()).unwrap()
2027 })
2028 .collect::<Vec<_>>();
2029
2030 assert_eq!(
2031 decoded
2032 .iter()
2033 .map(|close| (close.ts_event.as_u64(), close.ts_init.as_u64()))
2034 .collect::<Vec<_>>(),
2035 vec![(1, 4), (3, 6), (2, 5)]
2036 );
2037 assert_eq!(result.kind, LegacyTranscodeKind::InstrumentCloseV1);
2038 assert_eq!(result.batches.len(), 2);
2039 assert_eq!(
2040 decoded
2041 .iter()
2042 .map(|close| close.instrument_id.to_string())
2043 .collect::<Vec<_>>(),
2044 vec!["AUD/USD.SIM", "AUD/USD.SIM", "GBP/USD.SIM"]
2045 );
2046 assert_eq!(
2047 decoded
2048 .iter()
2049 .map(|close| close.close_price.to_string())
2050 .collect::<Vec<_>>(),
2051 vec!["1.0500", "1.0600", "2.1000"]
2052 );
2053 assert_eq!(
2054 decoded
2055 .iter()
2056 .map(|close| close.close_type)
2057 .collect::<Vec<_>>(),
2058 vec![
2059 InstrumentCloseType::EndOfSession,
2060 InstrumentCloseType::EndOfSession,
2061 InstrumentCloseType::ContractExpired,
2062 ]
2063 );
2064 }
2065
2066 #[rstest]
2067 fn instrument_close_rejects_mixed_precision() {
2068 let batch = RecordBatch::try_new(
2069 Arc::new(legacy_instrument_close_schema()),
2070 vec![
2071 Arc::new(StringArray::from(vec!["AUD/USD.SIM", "AUD/USD.SIM"])),
2072 Arc::new(StringArray::from(vec!["END_OF_SESSION", "END_OF_SESSION"])),
2073 Arc::new(StringArray::from(vec!["1.0500", "1.060"])),
2074 Arc::new(UInt64Array::from(vec![1, 2])),
2075 Arc::new(UInt64Array::from(vec![3, 4])),
2076 ],
2077 )
2078 .unwrap();
2079
2080 let error =
2081 transcode_legacy_record_batch("instrument_closes", "close.parquet", batch).unwrap_err();
2082
2083 assert!(error.to_string().contains("close.parquet"));
2084 assert!(error.to_string().contains("precision conflict at row 1"));
2085 }
2086
2087 #[rstest]
2088 fn instrument_close_rejects_unknown_close_type() {
2089 let batch = RecordBatch::try_new(
2090 Arc::new(legacy_instrument_close_schema()),
2091 vec![
2092 Arc::new(StringArray::from(vec!["AUD/USD.SIM"])),
2093 Arc::new(StringArray::from(vec!["UNKNOWN"])),
2094 Arc::new(StringArray::from(vec!["1.0500"])),
2095 Arc::new(UInt64Array::from(vec![1])),
2096 Arc::new(UInt64Array::from(vec![2])),
2097 ],
2098 )
2099 .unwrap();
2100
2101 let error =
2102 transcode_legacy_record_batch("instrument_closes", "close.parquet", batch).unwrap_err();
2103
2104 assert!(error.to_string().contains("close.parquet"));
2105 assert!(
2106 error
2107 .to_string()
2108 .contains("unknown close_type \"UNKNOWN\" at row 0")
2109 );
2110 }
2111}