1use std::{
19 collections::{BTreeMap, HashMap},
20 fmt::{Debug, Display},
21 sync::Arc,
22};
23
24use ahash::AHashSet;
25use arrow::{
26 array::{
27 Array, ArrayRef, BinaryArray, Decimal128Array, FixedSizeListArray, ListArray,
28 StringBuilder, StructArray, UInt32Array, UInt64Array,
29 },
30 buffer::{OffsetBuffer, ScalarBuffer},
31 compute::{cast, take_record_batch},
32 datatypes::{DataType as ArrowDataType, Field, Fields, Schema, TimeUnit},
33 record_batch::RecordBatch,
34};
35use futures::{StreamExt, TryStreamExt};
36use nautilus_model::{
37 data::{
38 Bar, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
39 MarkPriceUpdate, NautilusDataType, NautilusRecordType, OptionGreeks, OrderBookDelta,
40 OrderBookDepth, QuoteTick, TradeTick, depth::DEPTH10_LEN,
41 },
42 instruments::{InstrumentAny, NautilusInstrumentType},
43};
44use nautilus_serialization::arrow::{
45 ArrowSchemaProvider, EncodeToRecordBatch, KEY_BAR_TYPE, KEY_IDENTIFIER, KEY_INSTRUMENT_ID,
46 KEY_TYPE_NAME, StringColumnRef,
47 instrument::decode_instrument_any_batch,
48 legacy::{
49 LegacyArrowError, LegacySchemaResolution, LegacyTranscodeKind, LegacyTranscodeState,
50 SchemaFingerprint, is_nautilus_legacy_schema, is_nautilus_timestamp_schema,
51 normalize_legacy_fixed_columns,
52 normalized_legacy_data_type as normalize_legacy_arrow_data_type, normalized_timestamp_type,
53 resolve_legacy_schema, schema_fingerprint, transcode_legacy_record_batch_with_state,
54 },
55 record_batch_with_identifier_column, schema_without_identifier_column,
56};
57use object_store::{ObjectMeta, ObjectStore, ObjectStoreExt, path::Path as ObjectPath};
58use parquet::{read_parquet_from_object_store, read_parquet_schema_from_object_store};
59use serde::Serialize;
60use strum::IntoEnumIterator;
61
62use crate::{
63 backend::parquet::io as parquet,
64 catalog::types::{
65 INSTRUMENT_PATH_PREFIXES, data_path_prefix, data_type_from_data_path_prefix,
66 record_path_prefix,
67 },
68 common::{
69 arrow::{catalog_record_schema, round_trip_batches, round_trip_catalog_record_batches},
70 paths::normalize_path_separators,
71 storage::normalize_storage_location,
72 },
73};
74
75const SCHEMA_READ_CONCURRENCY: usize = 16;
76const LEGACY_KEY_CLASS: &str = "class";
77
78pub const DEFAULT_MIGRATION_COMMIT_ROWS: usize = 500_000;
80
81pub trait ParquetCatalogSource: Sync {
83 fn object_store(&self) -> Arc<dyn ObjectStore>;
85 fn base_path(&self) -> &str;
87 fn original_uri(&self) -> &str;
89
90 fn to_object_path_parsed(&self, path: &str) -> anyhow::Result<ObjectPath> {
96 let normalized = normalize_path_separators(path);
97 let base = self.base_path().trim_matches('/');
98
99 let full = if base.is_empty() {
100 normalized
101 } else {
102 format!("{base}/{}", normalized.trim_start_matches('/'))
103 };
104
105 ObjectPath::parse(full.trim_start_matches('/')).map_err(anyhow::Error::from)
106 }
107}
108
109#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
111pub struct CatalogMigrationTypeReport {
112 pub planned_files: usize,
113 pub migrated_files: usize,
114 pub skipped_files: usize,
115 pub migrated_rows: usize,
116 pub transcoded_rows: usize,
117 pub path_identifier_rows: usize,
118}
119
120#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
122pub struct CatalogMigrationReport {
123 pub dry_run: bool,
124 pub total_leaf_files: usize,
125 pub migrated_files: usize,
126 pub skipped_files: usize,
127 pub migrated_rows: usize,
128 pub transcoded_rows: usize,
129 pub path_identifier_rows: usize,
130 pub unmigrated: Vec<UnmigratedFile>,
131 pub types: BTreeMap<String, CatalogMigrationTypeReport>,
132}
133
134impl CatalogMigrationReport {
135 #[must_use]
137 pub fn from_plan(plan: &CatalogMigrationPlan, dry_run: bool) -> Self {
138 let mut types = BTreeMap::new();
139 for file in &plan.files {
140 types
141 .entry(file.target_type_name.clone())
142 .or_insert_with(CatalogMigrationTypeReport::default)
143 .planned_files += 1;
144 }
145
146 Self {
147 dry_run,
148 total_leaf_files: plan.total_leaf_files,
149 unmigrated: plan.unmigrated.clone(),
150 types,
151 ..Self::default()
152 }
153 }
154
155 pub(crate) fn record_migrated_file(
156 &mut self,
157 file: &PlannedMigrationFile,
158 rows: usize,
159 path_identifier_rows: usize,
160 ) {
161 let transcoded_rows = if file.transcode_kind == LegacyTranscodeKind::PassThrough {
162 0
163 } else {
164 rows
165 };
166
167 self.migrated_files += 1;
168 self.migrated_rows += rows;
169 self.transcoded_rows += transcoded_rows;
170 self.path_identifier_rows += path_identifier_rows;
171 let type_report = self.types.entry(file.target_type_name.clone()).or_default();
172 type_report.migrated_files += 1;
173 type_report.migrated_rows += rows;
174 type_report.transcoded_rows += transcoded_rows;
175 type_report.path_identifier_rows += path_identifier_rows;
176 }
177
178 pub(crate) fn record_skipped_file(&mut self, file: &PlannedMigrationFile) {
179 self.skipped_files += 1;
180 self.types
181 .entry(file.target_type_name.clone())
182 .or_default()
183 .skipped_files += 1;
184 }
185}
186
187impl Display for CatalogMigrationReport {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 writeln!(
190 f,
191 "{}: {} planned leaf files, {} migrated files, {} migrated rows, {} skipped files, {} \
192 unmigrated files",
193 if self.dry_run {
194 "Migration dry-run report"
195 } else {
196 "Migration report"
197 },
198 self.total_leaf_files,
199 self.migrated_files,
200 self.migrated_rows,
201 self.skipped_files,
202 self.unmigrated.len(),
203 )?;
204
205 for (type_name, report) in &self.types {
206 writeln!(
207 f,
208 "{type_name}: {} planned files, {} migrated files, {} rows, {} transcoded rows, {} \
209 path-derived identifier rows, {} skipped files",
210 report.planned_files,
211 report.migrated_files,
212 report.migrated_rows,
213 report.transcoded_rows,
214 report.path_identifier_rows,
215 report.skipped_files,
216 )?;
217 }
218
219 let mut unmigrated_directories = BTreeMap::new();
220
221 for file in &self.unmigrated {
222 let directory = file
223 .path
224 .rsplit_once('/')
225 .map_or(".", |(directory, _)| directory);
226 *unmigrated_directories.entry(directory).or_insert(0_usize) += 1;
227 }
228
229 for (directory, file_count) in unmigrated_directories {
230 writeln!(f, "Unmigrated directory {directory}: {file_count} files")?;
231 }
232
233 for file in &self.unmigrated {
234 writeln!(f, "Unmigrated {}: {}", file.path, file.reason)?;
235 }
236
237 Ok(())
238 }
239}
240
241#[derive(Clone, Debug)]
243pub struct PlannedMigrationFile {
244 pub path: String,
245 pub relative_path: String,
246 pub source_type_name: String,
247 pub target_type_name: String,
248 pub target_table: String,
249 pub size: u64,
250 pub e_tag: Option<String>,
251 pub version: Option<String>,
252 pub last_modified: String,
253 pub source_fingerprint: SchemaFingerprint,
254 pub target_fingerprint: SchemaFingerprint,
255 pub transcode_kind: LegacyTranscodeKind,
256}
257
258#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
260pub struct UnmigratedFile {
261 pub path: String,
262 pub reason: String,
263}
264
265#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct SchemaConflictExample {
268 pub fingerprint: SchemaFingerprint,
269 pub path: String,
270}
271
272#[derive(Clone, Debug, Eq, PartialEq)]
274pub struct SchemaConflict {
275 pub target_table: String,
276 pub examples: Vec<SchemaConflictExample>,
277}
278
279#[derive(Clone, Debug, Eq, PartialEq)]
281pub struct UnresolvedSchema {
282 pub path: String,
283 pub message: String,
284}
285
286#[derive(Clone, Debug, Default)]
288pub struct CatalogMigrationPlan {
289 pub files: Vec<PlannedMigrationFile>,
290 pub unmigrated: Vec<UnmigratedFile>,
291 pub conflicts: Vec<SchemaConflict>,
292 pub unresolved_schemas: Vec<UnresolvedSchema>,
293 pub total_leaf_files: usize,
294}
295
296impl CatalogMigrationPlan {
297 #[must_use]
299 pub const fn has_errors(&self) -> bool {
300 !self.conflicts.is_empty() || !self.unresolved_schemas.is_empty()
301 }
302
303 pub fn ensure_ready(&self) -> anyhow::Result<()> {
310 if !self.has_errors() {
311 return Ok(());
312 }
313
314 let mut messages = self
315 .conflicts
316 .iter()
317 .map(|conflict| {
318 let examples = conflict
319 .examples
320 .iter()
321 .map(|example| format!("{} ({})", example.path, example.fingerprint))
322 .collect::<Vec<_>>()
323 .join(", ");
324 format!(
325 "target table {} has conflicting schemas: {examples}",
326 conflict.target_table
327 )
328 })
329 .collect::<Vec<_>>();
330
331 messages.extend(
332 self.unresolved_schemas
333 .iter()
334 .map(|schema| schema.message.clone()),
335 );
336 anyhow::bail!(
337 "Catalog migration preflight failed:\n{}",
338 messages.join("\n")
339 );
340 }
341}
342
343#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
345pub enum IdentifierSource {
346 Metadata,
347 Row,
348 Path,
349 Absent,
350}
351
352#[derive(Debug)]
354pub struct PreparedMigrationPart {
355 pub identifier: Option<String>,
356 pub identifier_source: IdentifierSource,
357 pub batches: Vec<RecordBatch>,
358 pub row_count: usize,
359}
360
361pub fn build_catalog_migration_plan(
367 source: &dyn ParquetCatalogSource,
368) -> anyhow::Result<CatalogMigrationPlan> {
369 let objects = list_source_objects(source)?;
370 let total_leaf_files = objects.len();
371 let mut candidates = Vec::new();
372 let mut unmigrated = Vec::new();
373
374 for object in objects {
375 let relative_path = relative_object_path(source, &object.location);
376 match classify_source_path(&relative_path) {
377 SourceClassification::Migratable {
378 source_type_name,
379 target_type_name,
380 } if relative_path.ends_with(".parquet") => {
381 candidates.push(SchemaCandidate {
382 object_path: object.location.clone(),
383 object,
384 relative_path,
385 source_type_name,
386 target_type_name,
387 });
388 }
389 SourceClassification::Migratable { .. } => {
390 unmigrated.push(UnmigratedFile {
391 path: relative_path,
392 reason: "recognized catalog directory contains a non-Parquet leaf".to_string(),
393 });
394 }
395 SourceClassification::Unmigrated(reason) => {
396 unmigrated.push(UnmigratedFile {
397 path: relative_path,
398 reason,
399 });
400 }
401 }
402 }
403
404 let (mut files, unresolved_schemas) = resolve_candidate_schemas(source, candidates)?;
405 files.sort_by(|left, right| left.path.cmp(&right.path));
406 unmigrated.sort_by(|left, right| left.path.cmp(&right.path));
407 let conflicts = schema_conflicts(&files);
408
409 Ok(CatalogMigrationPlan {
410 files,
411 unmigrated,
412 conflicts,
413 unresolved_schemas,
414 total_leaf_files,
415 })
416}
417
418pub fn read_planned_migration_file(
424 source: &dyn ParquetCatalogSource,
425 file: &PlannedMigrationFile,
426) -> anyhow::Result<Vec<RecordBatch>> {
427 let object_path = source.to_object_path_parsed(&file.path)?;
428
429 let (batches, schema) = execute_async(|| async {
430 read_parquet_from_object_store(source.object_store(), &object_path).await
431 })?;
432
433 ensure_planned_file_unchanged(source, file, &object_path)?;
434 let mut state = LegacyTranscodeState::default();
435 let mut transcoded = Vec::new();
436
437 for batch in record_batches_with_schema(batches, &schema)? {
438 let batch = with_inferred_custom_type_name(file, batch)?;
439 let batch = normalize_legacy_parquet_columns(&batch)?;
440 let result = transcode_legacy_record_batch_with_state(
441 &file.target_type_name,
442 &file.path,
443 batch,
444 &mut state,
445 )?;
446 transcoded.extend(result.batches);
447 }
448
449 Ok(transcoded)
450}
451
452pub fn prepare_migration_parts(
460 file: &PlannedMigrationFile,
461 batches: Vec<RecordBatch>,
462) -> anyhow::Result<Vec<PreparedMigrationPart>> {
463 let mut grouped: BTreeMap<(Option<String>, IdentifierSource), Vec<RecordBatch>> =
464 BTreeMap::new();
465
466 for batch in batches {
467 for (identifier, source, batch) in split_batch_by_identifier(file, batch)? {
468 if batch.num_rows() == 0 {
469 continue;
470 }
471
472 let batch =
473 batch_with_identifier(&file.target_type_name, identifier.as_deref(), batch)?;
474 grouped.entry((identifier, source)).or_default().push(batch);
475 }
476 }
477
478 grouped
479 .into_iter()
480 .map(|((identifier, identifier_source), batches)| {
481 let row_count = batches.iter().map(RecordBatch::num_rows).sum();
482 let batches = round_trip_migrated_batches(&file.target_type_name, batches)?;
483 Ok(PreparedMigrationPart {
484 identifier,
485 identifier_source,
486 batches,
487 row_count,
488 })
489 })
490 .collect()
491}
492
493pub fn parse_storage_option(option: &str) -> Result<(String, String), String> {
499 let (key, value) = option
500 .split_once('=')
501 .ok_or_else(|| format!("Storage option must use key=value: {option}"))?;
502
503 if key.is_empty() || value.is_empty() {
504 return Err(format!(
505 "Storage option must use non-empty key=value: {option}"
506 ));
507 }
508
509 Ok((key.to_string(), value.to_string()))
510}
511
512pub(crate) fn ensure_distinct_migration_locations(
513 source_uri: &str,
514 target_uri: &str,
515) -> anyhow::Result<()> {
516 let source_uri = normalize_storage_location(source_uri)?;
517 let target_uri = normalize_storage_location(target_uri)?;
518 let source_uri = source_uri.trim_end_matches('/');
519 let target_uri = target_uri.trim_end_matches('/');
520 anyhow::ensure!(
521 source_uri != target_uri
522 && !target_uri.starts_with(&format!("{source_uri}/"))
523 && !source_uri.starts_with(&format!("{target_uri}/")),
524 "Migration source and target must be distinct, non-overlapping locations",
525 );
526 Ok(())
527}
528
529fn list_source_objects(source: &dyn ParquetCatalogSource) -> anyhow::Result<Vec<ObjectMeta>> {
530 let prefix =
531 (!source.base_path().is_empty()).then(|| ObjectPath::from(source.base_path().to_string()));
532
533 let mut objects = execute_async(|| async {
534 Ok(source
535 .object_store()
536 .list(prefix.as_ref())
537 .try_collect::<Vec<_>>()
538 .await?)
539 })?;
540
541 objects.sort_by(|left, right| left.location.as_ref().cmp(right.location.as_ref()));
544
545 let parents = objects
546 .windows(2)
547 .filter(|pair| {
548 pair[1]
549 .location
550 .as_ref()
551 .starts_with(&format!("{}/", pair[0].location.as_ref()))
552 })
553 .map(|pair| pair[0].location.clone())
554 .collect::<AHashSet<_>>();
555
556 objects.retain(|object| {
557 !parents.contains(&object.location)
558 && !relative_object_path(source, &object.location).is_empty()
559 });
560
561 Ok(objects)
562}
563
564fn execute_async<C, F, R>(create_future: C) -> anyhow::Result<R>
565where
566 C: FnOnce() -> F + Send,
567 F: std::future::Future<Output = anyhow::Result<R>>,
568 R: Send,
569{
570 nautilus_common::live::block_on_nautilus_with(create_future)
571}
572
573fn relative_object_path(source: &dyn ParquetCatalogSource, path: &ObjectPath) -> String {
574 let path = path.as_ref();
575 let base = source.base_path().trim_matches('/');
576 if base.is_empty() {
577 return path.to_string();
578 }
579
580 path.strip_prefix(&format!("{base}/"))
581 .unwrap_or(path)
582 .to_string()
583}
584
585#[derive(Clone, Debug)]
586enum SourceClassification {
587 Migratable {
588 source_type_name: String,
589 target_type_name: String,
590 },
591 Unmigrated(String),
592}
593
594fn classify_source_path(path: &str) -> SourceClassification {
595 let parts = path.split('/').collect::<Vec<_>>();
596
597 let Some(root) = parts.first().copied() else {
598 return SourceClassification::Unmigrated("empty source path".to_string());
599 };
600
601 if matches!(root, "backtest" | "live") {
602 return SourceClassification::Unmigrated(format!(
603 "{root} Feather trees are outside catalog migration scope"
604 ));
605 }
606
607 if root != "data" || parts.len() < 2 {
608 return SourceClassification::Unmigrated(
609 "path is outside the recognized data catalog tree".to_string(),
610 );
611 }
612
613 let source_type_name = parts[1];
614 if INSTRUMENT_PATH_PREFIXES.contains(&source_type_name) {
615 return SourceClassification::Migratable {
616 source_type_name: source_type_name.to_string(),
617 target_type_name: "instruments".to_string(),
618 };
619 }
620
621 if source_type_name == "custom" || source_type_name.starts_with("custom_") {
622 return SourceClassification::Migratable {
623 source_type_name: source_type_name.to_string(),
624 target_type_name: "custom".to_string(),
625 };
626 }
627
628 let data_type = match source_type_name {
630 "order_book_depth10" => Ok(NautilusDataType::OrderBookDepth),
631 _ => data_type_from_data_path_prefix(source_type_name),
632 };
633
634 if let Ok(data_type) = data_type {
635 return SourceClassification::Migratable {
636 source_type_name: source_type_name.to_string(),
637 target_type_name: data_path_prefix(&data_type).into_owned(),
638 };
639 }
640
641 if NautilusRecordType::iter()
642 .any(|record_type| record_path_prefix(&record_type).as_ref() == source_type_name)
643 {
644 return SourceClassification::Migratable {
645 source_type_name: source_type_name.to_string(),
646 target_type_name: source_type_name.to_string(),
647 };
648 }
649
650 let reason = if source_type_name == "portfolio_snapshot" {
651 "no NautilusRecordType maps to data/portfolio_snapshot"
652 } else {
653 "unrecognized catalog data directory"
654 };
655
656 SourceClassification::Unmigrated(reason.to_string())
657}
658
659#[derive(Clone, Debug)]
660struct SchemaCandidate {
661 object: ObjectMeta,
662 relative_path: String,
663 source_type_name: String,
664 target_type_name: String,
665 object_path: ObjectPath,
666}
667
668#[expect(
669 clippy::too_many_lines,
670 reason = "Preflight resolves every candidate before assembling the migration plan"
671)]
672fn resolve_candidate_schemas(
673 source: &dyn ParquetCatalogSource,
674 candidates: Vec<SchemaCandidate>,
675) -> anyhow::Result<(Vec<PlannedMigrationFile>, Vec<UnresolvedSchema>)> {
676 let object_store = source.object_store();
677
678 let resolved = execute_async(|| async move {
679 futures::stream::iter(candidates)
680 .map(|candidate| {
681 let object_store = object_store.clone();
682 async move {
683 let schema =
684 read_parquet_schema_from_object_store(object_store, &candidate.object_path)
685 .await?;
686 Ok::<_, anyhow::Error>((candidate, schema))
687 }
688 })
689 .buffer_unordered(SCHEMA_READ_CONCURRENCY)
690 .try_collect::<Vec<_>>()
691 .await
692 })?;
693
694 let mut files = Vec::new();
695 let mut unresolved = Vec::new();
696
697 for (mut candidate, schema) in resolved {
698 if candidate.object.size == 0 {
699 if candidate.target_type_name == "custom"
701 && let Some(inferred) = legacy_custom_type_name(&candidate.source_type_name)
702 {
703 candidate.target_type_name = format!("custom/{inferred}");
704 }
705
706 let fingerprint = schema_fingerprint(&Schema::empty());
707 files.push(ResolvedCandidate {
708 target_table: candidate.target_type_name.clone(),
709 candidate,
710 resolution: LegacySchemaResolution {
711 kind: LegacyTranscodeKind::PassThrough,
712 source_fingerprint: fingerprint.clone(),
713 target_fingerprint: fingerprint,
714 },
715 });
716
717 continue;
718 }
719
720 if candidate.target_type_name == "custom" {
723 if let Some(type_name) = schema.metadata().get("type_name") {
724 candidate.target_type_name = format!("custom/{type_name}");
725 } else if let Some(inferred) = legacy_custom_type_name(&candidate.source_type_name) {
726 candidate.target_type_name = format!("custom/{inferred}");
727 } else {
728 unresolved.push(UnresolvedSchema {
729 path: candidate.relative_path.clone(),
730 message: format!(
731 "Parquet custom data file {} is missing type_name metadata",
732 candidate.relative_path
733 ),
734 });
735
736 continue;
737 }
738 }
739
740 if candidate.target_type_name.starts_with("custom/")
743 && !custom_timestamps_convertible(&schema)
744 {
745 let detail = if schema.metadata().contains_key("type_name") {
746 "has non-UInt64 timestamps with no transcoder"
747 } else {
748 "is missing type_name metadata and has non-UInt64 timestamps with no transcoder"
749 };
750
751 unresolved.push(UnresolvedSchema {
752 path: candidate.relative_path.clone(),
753 message: format!(
754 "Parquet custom data file {} {detail}",
755 candidate.relative_path
756 ),
757 });
758
759 continue;
760 }
761
762 let schema = with_target_custom_type_name(&candidate, &schema);
763 let schema = normalize_legacy_parquet_schema(&schema);
764
765 let target_table = if candidate.target_type_name == "instruments" {
766 let Some(instrument_type) = schema.metadata().get(KEY_TYPE_NAME) else {
767 unresolved.push(UnresolvedSchema {
768 path: candidate.relative_path.clone(),
769 message: format!(
770 "Parquet instrument file {} is missing type_name metadata",
771 candidate.relative_path
772 ),
773 });
774
775 continue;
776 };
777
778 format!("instruments/{instrument_type}")
779 } else {
780 candidate.target_type_name.clone()
781 };
782
783 if let Ok(record_type) = candidate.target_type_name.parse::<NautilusRecordType>()
784 && let Ok(current) = catalog_record_schema(record_type)
785 {
786 let expected = schema_without_identifier_column(¤t);
787 let actual = schema_without_identifier_column(&schema);
788
789 if schema_fingerprint(&actual) != schema_fingerprint(&expected) {
790 unresolved.push(UnresolvedSchema {
791 path: candidate.relative_path.clone(),
792 message: format!(
793 "Record file {} does not match the registered Arrow schema",
794 candidate.relative_path
795 ),
796 });
797
798 continue;
799 }
800 }
801
802 if schema
803 .fields()
804 .iter()
805 .any(|field| contains_legacy_fixed_binary(field.data_type()))
806 {
807 unresolved.push(UnresolvedSchema {
808 path: candidate.relative_path.clone(),
809 message: format!(
810 "No final-format transcoder is registered for fixed binary columns in {}",
811 candidate.relative_path
812 ),
813 });
814
815 continue;
816 }
817
818 match resolve_legacy_schema(
819 &candidate.target_type_name,
820 &candidate.relative_path,
821 &schema,
822 ) {
823 Ok(resolution) => files.push(ResolvedCandidate {
824 candidate,
825 target_table,
826 resolution,
827 }),
828 Err(error @ LegacyArrowError::UnknownSchema { .. }) => {
829 unresolved.push(UnresolvedSchema {
830 path: candidate.relative_path,
831 message: error.to_string(),
832 });
833 }
834 Err(e) => return Err(e.into()),
835 }
836 }
837
838 let files = files
839 .into_iter()
840 .map(|resolved| PlannedMigrationFile {
841 path: resolved.candidate.object.location.to_string(),
842 relative_path: resolved.candidate.relative_path,
843 source_type_name: resolved.candidate.source_type_name,
844 target_type_name: resolved.candidate.target_type_name,
845 target_table: resolved.target_table,
846 size: resolved.candidate.object.size,
847 e_tag: resolved.candidate.object.e_tag,
848 version: resolved.candidate.object.version,
849 last_modified: resolved.candidate.object.last_modified.to_rfc3339(),
850 source_fingerprint: resolved.resolution.source_fingerprint,
851 target_fingerprint: resolved.resolution.target_fingerprint,
852 transcode_kind: resolved.resolution.kind,
853 })
854 .collect();
855
856 Ok((files, unresolved))
857}
858
859#[derive(Debug)]
860struct ResolvedCandidate {
861 candidate: SchemaCandidate,
862 target_table: String,
863 resolution: LegacySchemaResolution,
864}
865
866fn legacy_custom_type_name(source_type_name: &str) -> Option<String> {
873 let legacy = source_type_name.strip_prefix("custom_")?;
874 let mut pascal = String::with_capacity(legacy.len());
875 for part in legacy.split('_') {
876 let mut chars = part.chars();
877 if let Some(first) = chars.next() {
878 pascal.extend(first.to_uppercase());
879 pascal.extend(chars);
880 }
881 }
882
883 (!pascal.is_empty()).then_some(pascal)
884}
885
886fn custom_timestamps_convertible(schema: &Schema) -> bool {
890 ["ts_event", "ts_init"].iter().all(|name| {
891 schema.field_with_name(name).is_ok_and(|field| {
892 matches!(
893 field.data_type(),
894 ArrowDataType::UInt64 | ArrowDataType::Timestamp(TimeUnit::Nanosecond, _)
895 )
896 })
897 })
898}
899
900fn with_target_custom_type_name(candidate: &SchemaCandidate, schema: &Schema) -> Schema {
903 let Some(type_name) = candidate.target_type_name.strip_prefix("custom/") else {
904 return schema.clone();
905 };
906
907 inject_type_name_metadata(schema, type_name)
908}
909
910fn inject_type_name_metadata(schema: &Schema, type_name: &str) -> Schema {
913 let mut schema = schema.clone();
914 schema
915 .metadata
916 .entry("type_name".to_string())
917 .or_insert_with(|| type_name.to_string());
918 schema
919}
920
921fn contains_legacy_fixed_binary(data_type: &ArrowDataType) -> bool {
922 match data_type {
923 ArrowDataType::FixedSizeBinary(_) => true,
924 ArrowDataType::List(field)
925 | ArrowDataType::LargeList(field)
926 | ArrowDataType::FixedSizeList(field, _)
927 | ArrowDataType::Map(field, _) => contains_legacy_fixed_binary(field.data_type()),
928 ArrowDataType::Struct(fields) => fields
929 .iter()
930 .any(|field| contains_legacy_fixed_binary(field.data_type())),
931 ArrowDataType::Dictionary(_, value) => contains_legacy_fixed_binary(value),
932 _ => false,
933 }
934}
935
936pub(crate) fn ensure_planned_file_unchanged(
937 source: &dyn ParquetCatalogSource,
938 file: &PlannedMigrationFile,
939 object_path: &ObjectPath,
940) -> anyhow::Result<()> {
941 let object = execute_async(|| async { Ok(source.object_store().head(object_path).await?) })?;
942 anyhow::ensure!(
943 object.size == file.size
944 && object.e_tag == file.e_tag
945 && object.version == file.version
946 && object.last_modified.to_rfc3339() == file.last_modified,
947 "Source file changed after migration preflight: {}",
948 file.relative_path,
949 );
950 Ok(())
951}
952
953fn schema_conflicts(files: &[PlannedMigrationFile]) -> Vec<SchemaConflict> {
954 let mut by_table: BTreeMap<String, BTreeMap<String, SchemaConflictExample>> = BTreeMap::new();
955 for file in files.iter().filter(|file| file.size != 0) {
956 by_table
957 .entry(file.target_table.clone())
958 .or_default()
959 .entry(file.target_fingerprint.to_string())
960 .or_insert_with(|| SchemaConflictExample {
961 fingerprint: file.target_fingerprint.clone(),
962 path: file.relative_path.clone(),
963 });
964 }
965
966 by_table
967 .into_iter()
968 .filter_map(|(target_table, examples)| {
969 (examples.len() > 1).then(|| SchemaConflict {
970 target_table,
971 examples: examples.into_values().collect(),
972 })
973 })
974 .collect()
975}
976
977fn split_batch_by_identifier(
978 file: &PlannedMigrationFile,
979 batch: RecordBatch,
980) -> anyhow::Result<Vec<(Option<String>, IdentifierSource, RecordBatch)>> {
981 if let Some(identifier) = metadata_identifier(&file.target_type_name, &batch) {
982 return Ok(vec![(Some(identifier), IdentifierSource::Metadata, batch)]);
983 }
984
985 if let Some(column) = identifier_column(&file.target_type_name, &batch)? {
986 let groups = row_identifier_groups(&column, batch.num_rows())?;
987 return groups
988 .into_iter()
989 .map(|(identifier, indices)| {
990 let batch = take_record_batch(&batch, &UInt32Array::from(indices))?;
991 Ok((identifier, IdentifierSource::Row, batch))
992 })
993 .collect();
994 }
995
996 if let Some(identifier) =
997 record_identifier_from_path(&file.relative_path, &file.source_type_name)
998 {
999 return Ok(vec![(Some(identifier), IdentifierSource::Path, batch)]);
1000 }
1001
1002 Ok(vec![(None, IdentifierSource::Absent, batch)])
1003}
1004
1005fn metadata_identifier(type_name: &str, batch: &RecordBatch) -> Option<String> {
1006 batch
1007 .schema()
1008 .metadata()
1009 .get(identifier_metadata_key(type_name))
1010 .cloned()
1011}
1012
1013fn identifier_metadata_key(type_name: &str) -> &'static str {
1014 if type_name == "bars" {
1015 KEY_BAR_TYPE
1016 } else {
1017 KEY_INSTRUMENT_ID
1018 }
1019}
1020
1021fn identifier_column<'a>(
1022 type_name: &str,
1023 batch: &'a RecordBatch,
1024) -> anyhow::Result<Option<StringColumnRef<'a>>> {
1025 let candidates = if type_name == "bars" {
1026 [KEY_IDENTIFIER, KEY_BAR_TYPE, KEY_INSTRUMENT_ID, "id"]
1027 } else {
1028 [KEY_IDENTIFIER, KEY_INSTRUMENT_ID, KEY_BAR_TYPE, "id"]
1029 };
1030
1031 for name in candidates {
1032 if let Some(column) = batch.column_by_name(name) {
1033 return StringColumnRef::try_from_array(column.as_ref())
1034 .map(Some)
1035 .ok_or_else(|| anyhow::anyhow!("Identifier column {name} is not string-like"));
1036 }
1037 }
1038
1039 Ok(None)
1040}
1041
1042fn row_identifier_groups(
1043 column: &StringColumnRef<'_>,
1044 row_count: usize,
1045) -> anyhow::Result<BTreeMap<Option<String>, Vec<u32>>> {
1046 let mut groups: BTreeMap<Option<String>, Vec<u32>> = BTreeMap::new();
1047 for row in 0..row_count {
1048 groups
1049 .entry(column.value_opt(row).map(ToString::to_string))
1050 .or_default()
1051 .push(u32::try_from(row)?);
1052 }
1053
1054 Ok(groups)
1055}
1056
1057fn batch_with_identifier(
1058 type_name: &str,
1059 identifier: Option<&str>,
1060 batch: RecordBatch,
1061) -> anyhow::Result<RecordBatch> {
1062 let batch = record_batch_with_identifier_column(batch, identifier)?;
1063
1064 let Some(identifier) = identifier else {
1065 return Ok(batch);
1066 };
1067
1068 if type_name.starts_with("custom/") {
1069 return Ok(batch);
1070 }
1071
1072 let mut schema = batch.schema().as_ref().clone();
1073 schema.metadata.insert(
1074 identifier_metadata_key(type_name).to_string(),
1075 identifier.to_string(),
1076 );
1077 Ok(RecordBatch::try_new(
1078 Arc::new(schema),
1079 batch.columns().to_vec(),
1080 )?)
1081}
1082
1083fn round_trip_migrated_batches(
1086 type_name: &str,
1087 batches: Vec<RecordBatch>,
1088) -> anyhow::Result<Vec<RecordBatch>> {
1089 if let Ok(record_type) = type_name.parse::<NautilusRecordType>() {
1090 return round_trip_catalog_record_batches(record_type, batches);
1091 }
1092
1093 let data_type = data_type_from_data_path_prefix(type_name)?;
1094
1095 macro_rules! round_trip_data_type {
1096 (
1097 ($data_type:ident, $batches:ident);
1098 (Instrument, InstrumentAny, Instrument, Instrument, $instrument_prefix:literal),
1099 $(($variant:ident, $type:ident, $data:ident, $batch_variant:ident, $prefix:literal)),+ $(,)?
1100 ) => {
1101 match $data_type {
1102 NautilusDataType::Instrument => {
1103 let mut instruments = Vec::new();
1104
1105 for batch in &$batches {
1106 let metadata = batch.schema().metadata().clone();
1107 instruments.extend(decode_instrument_any_batch(&metadata, batch)?);
1108 }
1109
1110 let metadata = InstrumentAny::chunk_metadata(&instruments);
1111 Ok(vec![InstrumentAny::encode_batch(&metadata, &instruments)?])
1112 }
1113 $(
1114 NautilusDataType::$variant => {
1115 let batches = $batches
1116 .iter()
1117 .map(project_current_columns::<$type>)
1118 .collect::<anyhow::Result<Vec<_>>>()?;
1119 round_trip_batches::<$type>(batches)
1120 }
1121 )+
1122 NautilusDataType::Custom { .. } => Ok($batches),
1123 #[cfg(feature = "defi")]
1124 NautilusDataType::Defi => {
1125 anyhow::bail!("Parquet migration does not support DeFi data")
1126 }
1127 #[cfg(not(feature = "defi"))]
1128 #[allow(
1129 unreachable_patterns,
1130 reason = "DeFi variants can exist without this crate's defi feature"
1131 )]
1132 _ => anyhow::bail!("Parquet migration does not support DeFi data"),
1133 }
1134 };
1135 }
1136
1137 nautilus_model::for_each_data_type!(round_trip_data_type, data_type, batches)
1138}
1139
1140fn project_current_columns<T: ArrowSchemaProvider>(
1144 batch: &RecordBatch,
1145) -> anyhow::Result<RecordBatch> {
1146 let schema = batch.schema();
1147 let mut fields = Vec::new();
1148 let mut columns = Vec::new();
1149
1150 for expected in T::get_schema(None).fields() {
1151 if expected.name() == KEY_IDENTIFIER {
1152 continue;
1153 }
1154
1155 let index = schema.index_of(expected.name())?;
1156 fields.push(
1157 schema
1158 .field(index)
1159 .clone()
1160 .with_nullable(expected.is_nullable()),
1161 );
1162 columns.push(batch.column(index).clone());
1163 }
1164
1165 Ok(RecordBatch::try_new(
1166 Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone())),
1167 columns,
1168 )?)
1169}
1170
1171fn record_identifier_from_path(file_path: &str, type_name: &str) -> Option<String> {
1172 let path_parts = file_path.split('/').collect::<Vec<_>>();
1173 let type_parts = type_name.split('/').collect::<Vec<_>>();
1174
1175 for start in 0..path_parts.len() {
1176 if path_parts.get(start) != Some(&"data") {
1177 continue;
1178 }
1179
1180 let type_start = start + 1;
1181 let type_end = type_start + type_parts.len();
1182 if path_parts.get(type_start..type_end) != Some(type_parts.as_slice()) {
1183 continue;
1184 }
1185
1186 let remaining = &path_parts[type_end..];
1187 if remaining.len() <= 1 {
1188 return None;
1189 }
1190
1191 return Some(remaining[0].to_string());
1192 }
1193
1194 None
1195}
1196
1197fn record_batches_with_schema(
1198 batches: Vec<RecordBatch>,
1199 schema: &Arc<Schema>,
1200) -> anyhow::Result<Vec<RecordBatch>> {
1201 batches
1202 .into_iter()
1203 .map(|batch| {
1204 RecordBatch::try_new(schema.clone(), batch.columns().to_vec()).map_err(Into::into)
1205 })
1206 .collect()
1207}
1208
1209fn with_inferred_custom_type_name(
1215 file: &PlannedMigrationFile,
1216 batch: RecordBatch,
1217) -> anyhow::Result<RecordBatch> {
1218 let Some(type_name) = file.target_type_name.strip_prefix("custom/") else {
1219 return Ok(batch);
1220 };
1221
1222 let schema = Arc::new(inject_type_name_metadata(&batch.schema(), type_name));
1223 Ok(RecordBatch::try_new(schema, batch.columns().to_vec())?)
1224}
1225
1226fn normalize_legacy_parquet_columns(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1231 if let Some(schema) = normalize_legacy_record_schema(batch.schema_ref()) {
1232 let batch = normalize_legacy_info_column(batch)?;
1233 let batch = normalize_legacy_fixed_columns(&batch)?;
1234 return Ok(nautilus_serialization::arrow::record_batch_with_timestamps(
1235 Arc::new(schema),
1236 batch.columns().to_vec(),
1237 )?);
1238 }
1239
1240 if is_legacy_instrument_schema(batch.schema_ref()) {
1241 let metadata = legacy_instrument_metadata(batch.schema().metadata());
1242 if batch.num_rows() == 0 {
1243 return Ok(RecordBatch::new_empty(Arc::new(InstrumentAny::get_schema(
1244 Some(metadata),
1245 ))));
1246 }
1247
1248 let batch = normalize_dictionary_string_columns(batch)?;
1249 let batch = normalize_legacy_info_column(&batch)?;
1250 let instruments = decode_instrument_any_batch(&metadata, &batch)?;
1251 return Ok(InstrumentAny::encode_batch(&metadata, &instruments)?);
1252 }
1253
1254 let normalize_legacy = uses_legacy_string_encoding(batch.schema_ref());
1255 let batch = if normalize_legacy {
1256 normalize_dictionary_string_columns(batch)?
1257 } else {
1258 batch.clone()
1259 };
1260
1261 let batch = normalize_legacy_fixed_columns(&batch)?;
1262 let batch = normalize_legacy_info_column(&batch)?;
1263 normalize_legacy_depth_columns(&batch)
1264}
1265
1266#[must_use]
1268fn normalize_legacy_parquet_schema(schema: &Schema) -> Schema {
1269 if let Some(schema) = normalize_legacy_record_schema(schema) {
1270 return schema;
1271 }
1272
1273 if is_legacy_instrument_schema(schema) {
1274 return InstrumentAny::get_schema(Some(legacy_instrument_metadata(schema.metadata())));
1275 }
1276
1277 let normalize_fixed = is_nautilus_legacy_schema(schema);
1278 let normalize_strings = uses_legacy_string_encoding(schema);
1279 let normalize_timestamps = is_nautilus_timestamp_schema(schema);
1280
1281 let fields = schema
1282 .fields()
1283 .iter()
1284 .map(|field| {
1285 let data_type = if normalize_strings {
1286 normalized_dictionary_data_type(field.data_type())
1287 } else {
1288 field.data_type().clone()
1289 };
1290
1291 let data_type = normalized_legacy_data_type(field.name(), &data_type, normalize_fixed);
1292
1293 let data_type = if schema.metadata().contains_key("type_name")
1294 && matches!(field.name().as_str(), "ts_event" | "ts_init")
1295 && data_type == ArrowDataType::UInt64
1296 {
1297 nautilus_serialization::arrow::timestamp_data_type()
1298 } else if normalize_timestamps {
1299 normalized_timestamp_type(&data_type)
1300 } else {
1301 data_type
1302 };
1303
1304 let nullable = field.is_nullable()
1305 || (normalize_fixed
1306 && matches!(field.data_type(), ArrowDataType::FixedSizeBinary(8 | 16)))
1307 || (field.name() == "info" && field.data_type() == &ArrowDataType::Binary);
1308
1309 Arc::new(
1310 field
1311 .as_ref()
1312 .clone()
1313 .with_data_type(data_type)
1314 .with_nullable(nullable),
1315 )
1316 })
1317 .collect::<Vec<_>>();
1318
1319 normalize_legacy_depth_schema(Schema::new_with_metadata(fields, schema.metadata().clone()))
1320}
1321
1322fn uses_legacy_string_encoding(schema: &Schema) -> bool {
1323 is_nautilus_legacy_schema(schema)
1324 || (is_nautilus_timestamp_schema(schema)
1325 && schema
1326 .field_with_name("ts_init")
1327 .is_ok_and(|field| field.data_type() == &ArrowDataType::UInt64))
1328}
1329
1330fn normalize_legacy_record_schema(schema: &Schema) -> Option<Schema> {
1331 let record_type = schema
1332 .metadata()
1333 .get("type")?
1334 .parse::<NautilusRecordType>()
1335 .ok()?;
1336 let current = catalog_record_schema(record_type).ok()?;
1337
1338 let fields = schema
1339 .fields()
1340 .iter()
1341 .map(|field| {
1342 let Ok(expected) = current.field_with_name(field.name()) else {
1343 return field.clone();
1344 };
1345
1346 if field.data_type() == expected.data_type()
1347 || (expected.data_type() == &nautilus_serialization::arrow::timestamp_data_type()
1348 && (field.data_type() == &ArrowDataType::UInt64
1349 || normalized_timestamp_type(field.data_type()) == *expected.data_type()))
1350 || (field.name() == "info" && field.data_type() == &ArrowDataType::Binary)
1351 {
1352 Arc::new(expected.clone())
1353 } else {
1354 field.clone()
1355 }
1356 })
1357 .collect::<Vec<_>>();
1358
1359 Some(Schema::new_with_metadata(fields, schema.metadata().clone()))
1360}
1361
1362fn is_legacy_instrument_schema(schema: &Schema) -> bool {
1363 schema
1364 .metadata()
1365 .get(LEGACY_KEY_CLASS)
1366 .is_some_and(|type_name| type_name.parse::<NautilusInstrumentType>().is_ok())
1367 && schema.field_with_name("ts_init").is_ok_and(|field| {
1368 field.data_type() == &ArrowDataType::UInt64
1369 || field.data_type() == &nautilus_serialization::arrow::timestamp_data_type()
1370 })
1371}
1372
1373fn legacy_instrument_metadata(metadata: &HashMap<String, String>) -> HashMap<String, String> {
1375 let mut metadata = metadata.clone();
1376 if let Some(instrument_type) = metadata.remove(LEGACY_KEY_CLASS) {
1377 metadata.insert(KEY_TYPE_NAME.to_string(), instrument_type);
1378 }
1379
1380 metadata
1381}
1382
1383fn normalize_dictionary_string_columns(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1389 let schema = batch.schema();
1390 let mut changed = false;
1391 let mut fields = Vec::with_capacity(schema.fields().len());
1392 let mut columns: Vec<ArrayRef> = Vec::with_capacity(batch.num_columns());
1393
1394 for (field, column) in schema.fields().iter().zip(batch.columns()) {
1395 let data_type = normalized_dictionary_data_type(field.data_type());
1396
1397 changed |= &data_type != field.data_type();
1398 fields.push(Arc::new(
1399 field.as_ref().clone().with_data_type(data_type.clone()),
1400 ));
1401
1402 if column.data_type() == &data_type {
1403 columns.push(column.clone());
1404 } else {
1405 columns.push(cast(column.as_ref(), &data_type)?);
1406 }
1407 }
1408
1409 if !changed {
1410 return Ok(batch.clone());
1411 }
1412
1413 let schema = Arc::new(Schema::new_with_metadata(fields, schema.metadata().clone()));
1414 Ok(RecordBatch::try_new(schema, columns)?)
1415}
1416
1417fn normalized_legacy_data_type(
1418 name: &str,
1419 data_type: &ArrowDataType,
1420 normalize_fixed: bool,
1421) -> ArrowDataType {
1422 if normalize_fixed
1423 && let ArrowDataType::FixedSizeList(item, length) = data_type
1424 && matches!(item.data_type(), ArrowDataType::FixedSizeBinary(8 | 16))
1425 {
1426 return ArrowDataType::FixedSizeList(
1427 Arc::new(
1428 item.as_ref()
1429 .clone()
1430 .with_data_type(normalize_legacy_arrow_data_type(name, item.data_type()))
1431 .with_nullable(true),
1432 ),
1433 *length,
1434 );
1435 }
1436
1437 if normalize_fixed {
1438 let normalized = normalize_legacy_arrow_data_type(name, data_type);
1439 if &normalized != data_type {
1440 return normalized;
1441 }
1442 }
1443
1444 match data_type {
1445 ArrowDataType::Binary if name == "info" => ArrowDataType::Utf8,
1446 _ if normalize_fixed => normalized_dictionary_data_type(data_type),
1447 _ => data_type.clone(),
1448 }
1449}
1450
1451fn normalized_dictionary_data_type(data_type: &ArrowDataType) -> ArrowDataType {
1452 match data_type {
1453 ArrowDataType::Dictionary(_, value_type)
1454 if matches!(value_type.as_ref(), ArrowDataType::Utf8) =>
1455 {
1456 ArrowDataType::Utf8
1457 }
1458 _ => data_type.clone(),
1459 }
1460}
1461
1462fn normalize_legacy_info_column(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1463 let Some(info_index) = batch.schema().index_of("info").ok() else {
1464 return Ok(batch.clone());
1465 };
1466
1467 let column = batch.column(info_index);
1468 if column.data_type() != &ArrowDataType::Binary {
1469 return Ok(batch.clone());
1470 }
1471
1472 let info = column
1473 .as_any()
1474 .downcast_ref::<BinaryArray>()
1475 .expect("Binary column should downcast to BinaryArray");
1476
1477 let mut builder = StringBuilder::new();
1478
1479 for row in 0..info.len() {
1480 if info.is_null(row) || info.value(row) == b"null" {
1481 builder.append_null();
1482 } else {
1483 builder.append_value(std::str::from_utf8(info.value(row))?);
1484 }
1485 }
1486
1487 let mut fields = batch.schema().fields().iter().cloned().collect::<Vec<_>>();
1488
1489 fields[info_index] = Arc::new(
1490 fields[info_index]
1491 .as_ref()
1492 .clone()
1493 .with_data_type(ArrowDataType::Utf8)
1494 .with_nullable(true),
1495 );
1496 let mut columns = batch.columns().to_vec();
1497 columns[info_index] = Arc::new(builder.finish());
1498
1499 let schema = Arc::new(Schema::new_with_metadata(
1500 fields,
1501 batch.schema().metadata().clone(),
1502 ));
1503 Ok(RecordBatch::try_new(schema, columns)?)
1504}
1505
1506fn normalize_legacy_depth_schema(schema: Schema) -> Schema {
1507 if !has_legacy_depth_columns(&schema) {
1508 return schema;
1509 }
1510
1511 let mut fields = vec![depth_side_field("bids"), depth_side_field("asks")];
1512 fields.extend(
1513 schema
1514 .fields()
1515 .iter()
1516 .filter(|field| !is_legacy_depth_column(field.name()))
1517 .cloned(),
1518 );
1519 Schema::new_with_metadata(fields, schema.metadata().clone())
1520}
1521
1522fn normalize_legacy_depth_columns(batch: &RecordBatch) -> anyhow::Result<RecordBatch> {
1523 if !has_legacy_depth_columns(batch.schema().as_ref()) {
1524 return Ok(batch.clone());
1525 }
1526
1527 let flat = batch.schema().index_of("bid_price_0").is_ok();
1528
1529 let side_values = |side: &str, value: &str| {
1530 let name = format!("{side}_{value}");
1531
1532 if flat {
1533 return match value {
1534 "price" | "size" => decimal_depth_list(batch, &name),
1535 "count" => u32_depth_list(batch, &name),
1536 "order_id" => u64_depth_list(batch, &name),
1537 _ => unreachable!("depth field inventory is fixed"),
1538 }
1539 .and_then(|list| depth_list_values(&list));
1540 }
1541
1542 if let Some(column) = batch.column_by_name(&name) {
1543 return depth_list_values(column);
1544 }
1545
1546 anyhow::ensure!(
1547 matches!(value, "count" | "order_id"),
1548 "Missing legacy depth column '{name}'"
1549 );
1550 let width = legacy_fixed_list_width(batch, side)?;
1551
1552 let len = batch
1553 .num_rows()
1554 .checked_mul(width)
1555 .ok_or_else(|| anyhow::anyhow!("Legacy depth column '{name}' length overflow"))?;
1556
1557 match value {
1558 "count" => Ok(Arc::new(UInt32Array::from(vec![0; len])) as ArrayRef),
1559 "order_id" => Ok(Arc::new(UInt64Array::from(vec![0; len])) as ArrayRef),
1560 _ => unreachable!("missing legacy depth defaults are fixed"),
1561 }
1562 };
1563
1564 let mut columns = vec![
1565 depth_side_array(
1566 &side_values("bid", "price")?,
1567 &side_values("bid", "size")?,
1568 &side_values("bid", "count")?,
1569 &side_values("bid", "order_id")?,
1570 batch.num_rows(),
1571 )?,
1572 depth_side_array(
1573 &side_values("ask", "price")?,
1574 &side_values("ask", "size")?,
1575 &side_values("ask", "count")?,
1576 &side_values("ask", "order_id")?,
1577 batch.num_rows(),
1578 )?,
1579 ];
1580 columns.extend(
1581 batch
1582 .schema()
1583 .fields()
1584 .iter()
1585 .zip(batch.columns())
1586 .filter(|(field, _)| !is_legacy_depth_column(field.name()))
1587 .map(|(_, column)| column.clone()),
1588 );
1589
1590 let schema = Arc::new(normalize_legacy_depth_schema(
1591 batch.schema().as_ref().clone(),
1592 ));
1593 Ok(RecordBatch::try_new(schema, columns)?)
1594}
1595
1596fn legacy_fixed_list_width(batch: &RecordBatch, side: &str) -> anyhow::Result<usize> {
1597 let schema = batch.schema();
1598 ["price", "size", "count", "order_id"]
1599 .iter()
1600 .filter_map(|value| schema.field_with_name(&format!("{side}_{value}")).ok())
1601 .find_map(|field| match field.data_type() {
1602 ArrowDataType::FixedSizeList(_, width) => usize::try_from(*width).ok(),
1603 _ => None,
1604 })
1605 .ok_or_else(|| anyhow::anyhow!("Missing legacy depth FixedSizeList width for '{side}'"))
1606}
1607
1608fn has_legacy_depth_columns(schema: &Schema) -> bool {
1609 if schema.index_of("bids").is_ok() {
1610 return false;
1611 }
1612
1613 let has_fixed_lists = ["bid_price", "ask_price", "bid_size", "ask_size"]
1614 .iter()
1615 .all(|name| {
1616 schema
1617 .field_with_name(name)
1618 .is_ok_and(|field| matches!(field.data_type(), ArrowDataType::FixedSizeList(_, _)))
1619 });
1620
1621 let has_flat_levels = ["bid_price_0", "ask_price_0", "bid_size_0", "ask_size_0"]
1622 .iter()
1623 .all(|name| schema.index_of(name).is_ok());
1624
1625 has_fixed_lists || has_flat_levels
1626}
1627
1628fn depth_level_fields() -> Fields {
1629 vec![
1630 Field::new("price", ArrowDataType::Decimal128(38, 16), false),
1631 Field::new("size", ArrowDataType::Decimal128(38, 16), false),
1632 Field::new("count", ArrowDataType::UInt32, false),
1633 Field::new("order_id", ArrowDataType::UInt64, false),
1634 ]
1635 .into()
1636}
1637
1638fn depth_side_field(name: &str) -> Arc<Field> {
1639 let fields = depth_level_fields();
1640
1641 Arc::new(Field::new(
1642 name,
1643 ArrowDataType::List(Arc::new(Field::new(
1644 "item",
1645 ArrowDataType::Struct(fields),
1646 false,
1647 ))),
1648 false,
1649 ))
1650}
1651
1652fn decimal_depth_list(batch: &RecordBatch, prefix: &str) -> anyhow::Result<ArrayRef> {
1653 let arrays = (0..DEPTH10_LEN)
1654 .map(|level| {
1655 let name = format!("{prefix}_{level}");
1656 batch
1657 .column_by_name(&name)
1658 .and_then(|column| column.as_any().downcast_ref::<Decimal128Array>())
1659 .ok_or_else(|| anyhow::anyhow!("Legacy depth column '{name}' must be Decimal128"))
1660 })
1661 .collect::<Result<Vec<_>, _>>()?;
1662
1663 let mut values = Vec::with_capacity(batch.num_rows() * DEPTH10_LEN);
1664 for row in 0..batch.num_rows() {
1665 for array in &arrays {
1666 values.push((!array.is_null(row)).then(|| array.value(row)));
1667 }
1668 }
1669
1670 let values = Decimal128Array::from(values).with_precision_and_scale(38, 16)?;
1671 Ok(depth_list_array(Arc::new(values), true))
1672}
1673
1674fn u64_depth_list(batch: &RecordBatch, prefix: &str) -> anyhow::Result<ArrayRef> {
1675 let arrays = (0..DEPTH10_LEN)
1676 .map(|level| {
1677 let name = format!("{prefix}_{level}");
1678 batch
1679 .column_by_name(&name)
1680 .map(|column| {
1681 column
1682 .as_any()
1683 .downcast_ref::<UInt64Array>()
1684 .ok_or_else(|| {
1685 anyhow::anyhow!("Legacy depth column '{name}' must be UInt64")
1686 })
1687 })
1688 .transpose()
1689 })
1690 .collect::<Result<Vec<_>, _>>()?;
1691
1692 let mut values = Vec::with_capacity(batch.num_rows() * DEPTH10_LEN);
1693 for row in 0..batch.num_rows() {
1694 for array in &arrays {
1695 values.push(array.map_or(0, |array| array.value(row)));
1696 }
1697 }
1698
1699 Ok(depth_list_array(Arc::new(UInt64Array::from(values)), false))
1700}
1701
1702fn u32_depth_list(batch: &RecordBatch, prefix: &str) -> anyhow::Result<ArrayRef> {
1703 let arrays = (0..DEPTH10_LEN)
1704 .map(|level| {
1705 let name = format!("{prefix}_{level}");
1706 batch
1707 .column_by_name(&name)
1708 .and_then(|column| column.as_any().downcast_ref::<UInt32Array>())
1709 .ok_or_else(|| anyhow::anyhow!("Legacy depth column '{name}' must be UInt32"))
1710 })
1711 .collect::<Result<Vec<_>, _>>()?;
1712
1713 let mut values = Vec::with_capacity(batch.num_rows() * DEPTH10_LEN);
1714 for row in 0..batch.num_rows() {
1715 for array in &arrays {
1716 values.push(array.value(row));
1717 }
1718 }
1719
1720 Ok(depth_list_array(Arc::new(UInt32Array::from(values)), false))
1721}
1722
1723fn depth_list_array(values: ArrayRef, values_nullable: bool) -> ArrayRef {
1724 Arc::new(FixedSizeListArray::new(
1725 Arc::new(Field::new(
1726 "item",
1727 values.data_type().clone(),
1728 values_nullable,
1729 )),
1730 i32::try_from(DEPTH10_LEN).expect("depth-10 length fits i32"),
1731 values,
1732 None,
1733 ))
1734}
1735
1736fn depth_list_values(list: &ArrayRef) -> anyhow::Result<ArrayRef> {
1737 list.as_any()
1738 .downcast_ref::<FixedSizeListArray>()
1739 .map(|list| list.values().clone())
1740 .ok_or_else(|| anyhow::anyhow!("Legacy depth column must be FixedSizeList"))
1741}
1742
1743fn depth_side_array(
1744 prices: &ArrayRef,
1745 sizes: &ArrayRef,
1746 counts: &ArrayRef,
1747 order_ids: &ArrayRef,
1748 rows: usize,
1749) -> anyhow::Result<ArrayRef> {
1750 let prices = prices
1751 .as_any()
1752 .downcast_ref::<Decimal128Array>()
1753 .ok_or_else(|| anyhow::anyhow!("Legacy depth prices must be Decimal128"))?;
1754 let sizes = sizes
1755 .as_any()
1756 .downcast_ref::<Decimal128Array>()
1757 .ok_or_else(|| anyhow::anyhow!("Legacy depth sizes must be Decimal128"))?;
1758 let counts = counts
1759 .as_any()
1760 .downcast_ref::<UInt32Array>()
1761 .ok_or_else(|| anyhow::anyhow!("Legacy depth counts must be UInt32"))?;
1762 let order_ids = order_ids
1763 .as_any()
1764 .downcast_ref::<UInt64Array>()
1765 .ok_or_else(|| anyhow::anyhow!("Legacy depth order IDs must be UInt64"))?;
1766 let expected_len = prices.len();
1767 anyhow::ensure!(
1768 sizes.len() == expected_len
1769 && counts.len() == expected_len
1770 && order_ids.len() == expected_len,
1771 "Legacy depth columns must contain the same number of values"
1772 );
1773
1774 let width = if rows == 0 {
1775 0
1776 } else {
1777 anyhow::ensure!(
1778 expected_len.is_multiple_of(rows),
1779 "Legacy depth column length {expected_len} is not divisible by row count {rows}"
1780 );
1781 expected_len / rows
1782 };
1783
1784 let mut open_prices = Vec::with_capacity(expected_len);
1785 let mut open_sizes = Vec::with_capacity(expected_len);
1786 let mut open_counts = Vec::with_capacity(expected_len);
1787 let mut open_order_ids = Vec::with_capacity(expected_len);
1788 let mut offsets = Vec::with_capacity(rows + 1);
1789 offsets.push(0);
1790
1791 for row in 0..rows {
1792 for level in 0..width {
1793 let index = row * width + level;
1794 if prices.is_null(index) || sizes.is_null(index) {
1795 continue;
1796 }
1797
1798 open_prices.push(prices.value(index));
1799 open_sizes.push(sizes.value(index));
1800 open_counts.push(counts.value(index));
1801 open_order_ids.push(order_ids.value(index));
1802 }
1803
1804 offsets.push(i32::try_from(open_prices.len())?);
1805 }
1806
1807 let fields = depth_level_fields();
1808 let values = StructArray::try_new(
1809 fields.clone(),
1810 vec![
1811 Arc::new(Decimal128Array::from(open_prices).with_precision_and_scale(38, 16)?),
1812 Arc::new(Decimal128Array::from(open_sizes).with_precision_and_scale(38, 16)?),
1813 Arc::new(UInt32Array::from(open_counts)),
1814 Arc::new(UInt64Array::from(open_order_ids)),
1815 ],
1816 None,
1817 )?;
1818 Ok(Arc::new(ListArray::try_new(
1819 Arc::new(Field::new("item", ArrowDataType::Struct(fields), false)),
1820 OffsetBuffer::new(ScalarBuffer::from(offsets)),
1821 Arc::new(values),
1822 None,
1823 )?))
1824}
1825
1826fn is_legacy_depth_column(name: &str) -> bool {
1827 const LIST_COLUMNS: &[&str] = &[
1828 "bid_price",
1829 "ask_price",
1830 "bid_size",
1831 "ask_size",
1832 "bid_order_id",
1833 "ask_order_id",
1834 "bid_count",
1835 "ask_count",
1836 ];
1837 LIST_COLUMNS.iter().any(|column| {
1838 name.strip_prefix(column).is_some_and(|suffix| {
1839 suffix.is_empty()
1840 || suffix
1841 .strip_prefix('_')
1842 .and_then(|level| level.parse::<usize>().ok())
1843 .is_some_and(|level| level < DEPTH10_LEN)
1844 })
1845 })
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850 use std::{collections::HashMap, fs, sync::Arc};
1851
1852 use ::parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
1853 use arrow::{
1854 array::{
1855 BooleanArray, FixedSizeBinaryArray, Float64Array, Int64Array, StringArray,
1856 StringDictionaryBuilder, TimestampNanosecondArray, UInt8Array, UInt64Array,
1857 },
1858 datatypes::{DataType, Field, Int8Type, Schema},
1859 record_batch::RecordBatch,
1860 };
1861 use nautilus_core::UnixNanos;
1862 use nautilus_model::{
1863 data::{greeks::OptionGreekValues, stubs::stub_depth10},
1864 enums::{GreeksConvention, MarketStatusAction},
1865 identifiers::InstrumentId,
1866 types::{Price, Quantity},
1867 };
1868 use nautilus_serialization::arrow::{
1869 DecodeFromRecordBatch, KEY_PRICE_PRECISION, KEY_TYPE_NAME,
1870 record_batch_without_identifier_column, timestamp_array, timestamp_data_type,
1871 };
1872 use rstest::rstest;
1873 use tempfile::TempDir;
1874
1875 use super::*;
1876
1877 #[rstest]
1878 fn storage_options_require_non_empty_key_and_value() {
1879 assert_eq!(
1880 parse_storage_option("region=us-east-1"),
1881 Ok(("region".to_string(), "us-east-1".to_string()))
1882 );
1883 assert_eq!(
1884 parse_storage_option("region").unwrap_err(),
1885 "Storage option must use key=value: region"
1886 );
1887 assert_eq!(
1888 parse_storage_option("=us-east-1").unwrap_err(),
1889 "Storage option must use non-empty key=value: =us-east-1"
1890 );
1891 assert_eq!(
1892 parse_storage_option("region=").unwrap_err(),
1893 "Storage option must use non-empty key=value: region="
1894 );
1895 }
1896
1897 #[rstest]
1898 fn dry_run_report_lists_unmigrated_files_and_reasons() {
1899 let plan = CatalogMigrationPlan {
1900 total_leaf_files: 1,
1901 unmigrated: vec![UnmigratedFile {
1902 path: "backtest/run/data.feather".to_string(),
1903 reason: "Feather trees are outside catalog migration scope".to_string(),
1904 }],
1905 ..CatalogMigrationPlan::default()
1906 };
1907
1908 let report = CatalogMigrationReport::from_plan(&plan, true).to_string();
1909
1910 assert!(report.contains("Migration dry-run report: 1 planned leaf files"));
1911 assert!(report.contains("Unmigrated directory backtest/run: 1 files"));
1912 assert!(report.contains(
1913 "Unmigrated backtest/run/data.feather: Feather trees are outside catalog migration scope"
1914 ));
1915 }
1916
1917 #[rstest]
1918 #[case("/tmp/source", "/tmp/source")]
1919 #[case("/tmp/source/", "/tmp/source")]
1920 #[case("/tmp/source", "/tmp/source/target")]
1921 #[case("/tmp/source/child", "/tmp/source")]
1922 fn migration_locations_must_not_overlap(#[case] source: &str, #[case] target: &str) {
1923 assert_eq!(
1924 ensure_distinct_migration_locations(source, target)
1925 .unwrap_err()
1926 .to_string(),
1927 "Migration source and target must be distinct, non-overlapping locations"
1928 );
1929 }
1930
1931 #[rstest]
1932 fn migration_locations_normalize_local_paths() {
1933 let source = std::env::current_dir().unwrap().join("catalog");
1934 let source_uri = normalize_storage_location(source.to_str().unwrap()).unwrap();
1935
1936 assert!(ensure_distinct_migration_locations("catalog", &source_uri).is_err());
1937 assert!(ensure_distinct_migration_locations("catalog-a", "catalog-b").is_ok());
1938 }
1939
1940 #[rstest]
1941 #[case("depths.parquet", "order_book_depths")]
1942 #[case("quotes.parquet", "quotes")]
1943 #[case("trades.parquet", "trades")]
1944 #[case("bars.parquet", "bars")]
1945 #[case("deltas.parquet", "order_book_deltas")]
1946 fn committed_legacy_fixture_plans_as_pass_through(
1947 #[case] file_name: &str,
1948 #[case] type_name: &str,
1949 ) {
1950 let precision_dir = if cfg!(feature = "high-precision") {
1951 "128-bit"
1952 } else {
1953 "64-bit"
1954 };
1955
1956 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1957 .join("../../test_data/nautilus/legacy")
1958 .join(precision_dir)
1959 .join(file_name);
1960 let file = std::fs::File::open(path).unwrap();
1961 let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
1962 let schema = normalize_legacy_parquet_schema(builder.schema().as_ref());
1963
1964 let resolution = resolve_legacy_schema(type_name, file_name, &schema).unwrap();
1965
1966 assert_eq!(resolution.kind, LegacyTranscodeKind::PassThrough);
1967 }
1968
1969 #[rstest]
1970 fn classify_source_path_maps_legacy_depth_directory() {
1971 let classification =
1972 classify_source_path("data/order_book_depth10/AAPL.XNAS/part-0.parquet");
1973
1974 assert!(matches!(
1975 classification,
1976 SourceClassification::Migratable { source_type_name, target_type_name }
1977 if source_type_name == "order_book_depth10"
1978 && target_type_name == "order_book_depths"
1979 ));
1980 }
1981
1982 #[rstest]
1983 fn migration_plan_discovers_bar_file_under_bare_instrument_folder() {
1984 let temp = TempDir::new().unwrap();
1985 let source_path = temp.path().join("source");
1986 let bar_dir = source_path.join("data").join("bars").join("AUDUSD.SIM");
1987 fs::create_dir_all(&bar_dir).unwrap();
1988
1989 let precision_dir = if cfg!(feature = "high-precision") {
1990 "128-bit"
1991 } else {
1992 "64-bit"
1993 };
1994
1995 let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1996 .join("../../test_data/nautilus/legacy")
1997 .join(precision_dir)
1998 .join("bars.parquet");
1999 fs::copy(fixture, bar_dir.join("bars.parquet")).unwrap();
2000 let source = crate::backend::parquet::catalog::ParquetDataCatalog::from_uri(
2001 source_path.to_str().unwrap(),
2002 None,
2003 None,
2004 None,
2005 None,
2006 )
2007 .unwrap();
2008
2009 let plan = build_catalog_migration_plan(&source).unwrap();
2010
2011 assert_eq!(plan.total_leaf_files, 1);
2012 assert_eq!(plan.files.len(), 1);
2013 assert_eq!(
2014 plan.files[0].relative_path,
2015 "data/bars/AUDUSD.SIM/bars.parquet",
2016 );
2017 assert_eq!(plan.files[0].source_type_name, "bars");
2018 assert_eq!(plan.files[0].target_type_name, "bars");
2019 assert!(plan.unmigrated.is_empty());
2020 assert!(plan.unresolved_schemas.is_empty());
2021 plan.ensure_ready().unwrap();
2022 }
2023
2024 #[rstest]
2025 fn migration_plan_rejects_unrecognized_schema_during_preflight() {
2026 let temp = TempDir::new().unwrap();
2027 let source_path = temp.path().join("source");
2028 let quote_dir = source_path.join("data").join("quotes").join("AUDUSD.SIM");
2029 fs::create_dir_all("e_dir).unwrap();
2030
2031 let schema = Arc::new(Schema::new(vec![Field::new(
2032 "unknown",
2033 DataType::Int64,
2034 false,
2035 )]));
2036 let batch = RecordBatch::try_new(
2037 schema.clone(),
2038 vec![Arc::new(Int64Array::from(vec![1_i64]))],
2039 )
2040 .unwrap();
2041 let file = fs::File::create(quote_dir.join("unknown.parquet")).unwrap();
2042 let mut writer = ::parquet::arrow::ArrowWriter::try_new(file, schema, None).unwrap();
2043 writer.write(&batch).unwrap();
2044 writer.close().unwrap();
2045 let source = crate::backend::parquet::catalog::ParquetDataCatalog::from_uri(
2046 source_path.to_str().unwrap(),
2047 None,
2048 None,
2049 None,
2050 None,
2051 )
2052 .unwrap();
2053
2054 let plan = build_catalog_migration_plan(&source).unwrap();
2055 let error = plan.ensure_ready().unwrap_err();
2056
2057 assert_eq!(plan.total_leaf_files, 1);
2058 assert!(plan.files.is_empty());
2059 assert_eq!(plan.unresolved_schemas.len(), 1);
2060 assert_eq!(
2061 plan.unresolved_schemas[0].path,
2062 "data/quotes/AUDUSD.SIM/unknown.parquet",
2063 );
2064 assert_eq!(
2065 error.to_string(),
2066 format!(
2067 "Catalog migration preflight failed:\n{}",
2068 plan.unresolved_schemas[0].message,
2069 ),
2070 );
2071 }
2072
2073 #[rstest]
2074 #[case::bars(
2075 "bars",
2076 Some("AUD/USD.SIM-1-MINUTE-BID-EXTERNAL"),
2077 Some("AUD/USD.SIM-1-MINUTE-BID-EXTERNAL"),
2078 None
2079 )]
2080 #[case::quotes("quotes", Some("AUD/USD.SIM"), None, Some("AUD/USD.SIM"))]
2081 #[case::custom("custom/RustTestCustomData", Some("AUD/USD.SIM"), None, None)]
2082 #[case::no_identifier("quotes", None, None, None)]
2083 fn batch_with_identifier_writes_the_type_specific_metadata_key(
2084 #[case] type_name: &str,
2085 #[case] identifier: Option<&str>,
2086 #[case] expected_bar_type: Option<&str>,
2087 #[case] expected_instrument_id: Option<&str>,
2088 ) {
2089 let batch = RecordBatch::try_new(
2090 Arc::new(Schema::new(vec![Field::new(
2091 "ts_init",
2092 DataType::Int64,
2093 false,
2094 )])),
2095 vec![Arc::new(Int64Array::from(vec![1]))],
2096 )
2097 .unwrap();
2098
2099 let batch = batch_with_identifier(type_name, identifier, batch).unwrap();
2100
2101 let schema = batch.schema();
2102 let metadata = schema.metadata();
2103 assert_eq!(
2104 metadata.get(KEY_BAR_TYPE).map(String::as_str),
2105 expected_bar_type
2106 );
2107 assert_eq!(
2108 metadata.get(KEY_INSTRUMENT_ID).map(String::as_str),
2109 expected_instrument_id
2110 );
2111 assert_eq!(
2112 metadata_identifier(type_name, &batch).as_deref(),
2113 expected_bar_type.or(expected_instrument_id)
2114 );
2115 }
2116
2117 #[rstest]
2118 #[case::absent(None, "Planned")]
2119 #[case::present(Some("Existing"), "Existing")]
2120 fn inject_type_name_metadata_keeps_an_existing_type_name(
2121 #[case] existing: Option<&str>,
2122 #[case] expected: &str,
2123 ) {
2124 let metadata = existing
2125 .map(|name| HashMap::from([("type_name".to_string(), name.to_string())]))
2126 .unwrap_or_default();
2127 let schema = Schema::new_with_metadata(
2128 vec![Field::new("ts_init", DataType::Int64, false)],
2129 metadata,
2130 );
2131
2132 let injected = inject_type_name_metadata(&schema, "Planned");
2133
2134 assert_eq!(
2135 injected.metadata().get("type_name").map(String::as_str),
2136 Some(expected)
2137 );
2138 assert_eq!(injected.fields(), schema.fields());
2139 }
2140
2141 #[rstest]
2142 fn prepare_migration_parts_splits_legacy_status_and_matches_fresh_writes() {
2143 let expected = [
2144 InstrumentStatus::new(
2145 InstrumentId::from("AAA.XNAS"),
2146 MarketStatusAction::Trading,
2147 UnixNanos::from(1),
2148 UnixNanos::from(2),
2149 Some("Normal trading".into()),
2150 None,
2151 Some(true),
2152 None,
2153 Some(false),
2154 ),
2155 InstrumentStatus::new(
2156 InstrumentId::from("BBB.XNAS"),
2157 MarketStatusAction::Halt,
2158 UnixNanos::from(3),
2159 UnixNanos::from(4),
2160 None,
2161 Some("MARKET_HALT".into()),
2162 Some(false),
2163 Some(false),
2164 None,
2165 ),
2166 ];
2167
2168 let source = RecordBatch::try_new(
2170 Arc::new(Schema::new(vec![
2171 Field::new("instrument_id", DataType::Utf8, true),
2172 Field::new("action", DataType::Utf8, true),
2173 Field::new("reason", DataType::Utf8, true),
2174 Field::new("trading_event", DataType::Utf8, true),
2175 Field::new("is_trading", DataType::Boolean, true),
2176 Field::new("is_quoting", DataType::Boolean, true),
2177 Field::new("is_short_sell_restricted", DataType::Boolean, true),
2178 Field::new("ts_event", DataType::UInt64, true),
2179 Field::new("ts_init", DataType::UInt64, true),
2180 ])),
2181 vec![
2182 Arc::new(StringArray::from(vec!["AAA.XNAS", "BBB.XNAS"])),
2183 Arc::new(StringArray::from(vec!["TRADING", "HALT"])),
2184 Arc::new(StringArray::from(vec![Some("Normal trading"), None])),
2185 Arc::new(StringArray::from(vec![None, Some("MARKET_HALT")])),
2186 Arc::new(BooleanArray::from(vec![Some(true), Some(false)])),
2187 Arc::new(BooleanArray::from(vec![None, Some(false)])),
2188 Arc::new(BooleanArray::from(vec![Some(false), None])),
2189 Arc::new(UInt64Array::from(vec![1, 3])),
2190 Arc::new(UInt64Array::from(vec![2, 4])),
2191 ],
2192 )
2193 .unwrap();
2194
2195 let parts = migrate_source_batches("instrument_status", &[source]);
2196
2197 assert_eq!(
2198 parts
2199 .iter()
2200 .map(|part| (part.identifier.as_deref(), part.identifier_source))
2201 .collect::<Vec<_>>(),
2202 vec![
2203 (Some("AAA.XNAS"), IdentifierSource::Row),
2204 (Some("BBB.XNAS"), IdentifierSource::Row),
2205 ]
2206 );
2207
2208 for (part, expected) in parts.into_iter().zip(expected) {
2209 assert_part_matches_fresh_write(part, &[expected]);
2210 }
2211 }
2212
2213 #[rstest]
2214 fn prepare_migration_parts_reorders_legacy_greeks_and_matches_fresh_writes() {
2215 let instrument_id = InstrumentId::from("BTC-20260529-100000-C.OKX");
2216
2217 let expected = OptionGreeks {
2218 instrument_id,
2219 convention: GreeksConvention::PriceAdjusted,
2220 greeks: OptionGreekValues {
2221 delta: 0.55,
2222 gamma: 0.012,
2223 vega: 3.4,
2224 theta: -1.2,
2225 rho: 0.01,
2226 },
2227 mark_iv: Some(0.64),
2228 bid_iv: None,
2229 ask_iv: Some(0.66),
2230 underlying_price: Some(100_000.0),
2231 open_interest: None,
2232 ts_event: UnixNanos::from(5),
2233 ts_init: UnixNanos::from(6),
2234 };
2235
2236 let float_field = |name| Field::new(name, DataType::Float64, false);
2238 let optional_float_field = |name| Field::new(name, DataType::Float64, true);
2239 let source = RecordBatch::try_new(
2240 Arc::new(Schema::new_with_metadata(
2241 vec![
2242 Field::new("instrument_id", DataType::Utf8, false),
2243 float_field("delta"),
2244 float_field("gamma"),
2245 float_field("vega"),
2246 float_field("theta"),
2247 float_field("rho"),
2248 optional_float_field("mark_iv"),
2249 optional_float_field("bid_iv"),
2250 optional_float_field("ask_iv"),
2251 optional_float_field("underlying_price"),
2252 optional_float_field("open_interest"),
2253 Field::new("ts_event", DataType::UInt64, false),
2254 Field::new("ts_init", DataType::UInt64, false),
2255 Field::new("convention", DataType::Utf8, false),
2256 ],
2257 HashMap::from([("type".to_string(), "OptionGreeks".to_string())]),
2258 )),
2259 vec![
2260 Arc::new(StringArray::from(vec![instrument_id.to_string()])),
2261 Arc::new(Float64Array::from(vec![0.55])),
2262 Arc::new(Float64Array::from(vec![0.012])),
2263 Arc::new(Float64Array::from(vec![3.4])),
2264 Arc::new(Float64Array::from(vec![-1.2])),
2265 Arc::new(Float64Array::from(vec![0.01])),
2266 Arc::new(Float64Array::from(vec![Some(0.64)])),
2267 Arc::new(Float64Array::from(vec![None])),
2268 Arc::new(Float64Array::from(vec![Some(0.66)])),
2269 Arc::new(Float64Array::from(vec![Some(100_000.0)])),
2270 Arc::new(Float64Array::from(vec![None])),
2271 Arc::new(UInt64Array::from(vec![5])),
2272 Arc::new(UInt64Array::from(vec![6])),
2273 Arc::new(StringArray::from(vec!["PRICE_ADJUSTED"])),
2274 ],
2275 )
2276 .unwrap();
2277
2278 let parts = migrate_source_batches("option_greeks", &[source]);
2279
2280 assert_eq!(parts.len(), 1);
2281 assert_eq!(
2282 parts[0].identifier.as_deref(),
2283 Some("BTC-20260529-100000-C.OKX")
2284 );
2285 assert_eq!(
2286 parts[0].batches[0].schema().metadata().get(KEY_TYPE_NAME),
2287 Some(&"OptionGreeks".to_string())
2288 );
2289 assert_part_matches_fresh_write(parts.into_iter().next().unwrap(), &[expected]);
2290 }
2291
2292 #[rstest]
2293 fn prepare_migration_parts_rejects_null_in_required_column() {
2294 let source = RecordBatch::try_new(
2295 Arc::new(Schema::new_with_metadata(
2296 vec![
2297 Field::new("instrument_id", DataType::Utf8, false),
2298 Field::new("convention", DataType::Utf8, false),
2299 Field::new("delta", DataType::Float64, true),
2300 Field::new("gamma", DataType::Float64, false),
2301 Field::new("vega", DataType::Float64, false),
2302 Field::new("theta", DataType::Float64, false),
2303 Field::new("rho", DataType::Float64, false),
2304 Field::new("mark_iv", DataType::Float64, true),
2305 Field::new("bid_iv", DataType::Float64, true),
2306 Field::new("ask_iv", DataType::Float64, true),
2307 Field::new("underlying_price", DataType::Float64, true),
2308 Field::new("open_interest", DataType::Float64, true),
2309 Field::new("ts_event", timestamp_data_type(), false),
2310 Field::new("ts_init", timestamp_data_type(), false),
2311 ],
2312 HashMap::from([("type".to_string(), "OptionGreeks".to_string())]),
2313 )),
2314 vec![
2315 Arc::new(StringArray::from(vec!["BTC-20260529-100000-C.OKX"])),
2316 Arc::new(StringArray::from(vec!["BLACK_SCHOLES"])),
2317 Arc::new(Float64Array::from(vec![None])),
2318 Arc::new(Float64Array::from(vec![0.012])),
2319 Arc::new(Float64Array::from(vec![3.4])),
2320 Arc::new(Float64Array::from(vec![-1.2])),
2321 Arc::new(Float64Array::from(vec![0.01])),
2322 Arc::new(Float64Array::from(vec![None])),
2323 Arc::new(Float64Array::from(vec![None])),
2324 Arc::new(Float64Array::from(vec![None])),
2325 Arc::new(Float64Array::from(vec![None])),
2326 Arc::new(Float64Array::from(vec![None])),
2327 Arc::new(timestamp_array([5]).unwrap()),
2328 Arc::new(timestamp_array([6]).unwrap()),
2329 ],
2330 )
2331 .unwrap();
2332
2333 let error = try_migrate_source_batches("option_greeks", &[source]).unwrap_err();
2334
2335 assert_eq!(
2336 error.to_string(),
2337 "Invalid argument error: Column 'delta' is declared as non-nullable but contains null values"
2338 );
2339 }
2340
2341 #[rstest]
2342 fn prepare_migration_parts_selects_metadata_across_every_batch_of_a_file() {
2343 let populated = stub_depth10();
2344 let mut empty = populated.clone();
2345 empty.bids.clear();
2346 empty.asks.clear();
2347 empty.bid_counts.clear();
2348 empty.ask_counts.clear();
2349 let expected = [empty, populated];
2350
2351 let file_metadata = OrderBookDepth::chunk_metadata(&expected);
2354
2355 let sources = expected
2356 .iter()
2357 .map(|depth| {
2358 let batch =
2359 OrderBookDepth::encode_batch(&file_metadata, std::slice::from_ref(depth))
2360 .unwrap();
2361 record_batch_without_identifier_column(batch).unwrap()
2362 })
2363 .collect::<Vec<_>>();
2364
2365 let parts = migrate_source_batches("order_book_depths", &sources);
2366
2367 assert_eq!(parts.len(), 1);
2368 assert_eq!(
2369 parts[0]
2370 .batches
2371 .iter()
2372 .map(|batch| batch.schema().metadata()[KEY_PRICE_PRECISION].clone())
2373 .collect::<Vec<_>>(),
2374 vec!["2", "2"]
2375 );
2376 assert_part_matches_fresh_write(parts.into_iter().next().unwrap(), &expected);
2377 }
2378
2379 fn migrate_source_batches(
2381 type_name: &str,
2382 sources: &[RecordBatch],
2383 ) -> Vec<PreparedMigrationPart> {
2384 try_migrate_source_batches(type_name, sources).unwrap()
2385 }
2386
2387 fn try_migrate_source_batches(
2388 type_name: &str,
2389 sources: &[RecordBatch],
2390 ) -> anyhow::Result<Vec<PreparedMigrationPart>> {
2391 let mut state = LegacyTranscodeState::default();
2392 let mut batches = Vec::new();
2393 let mut transcode_kind = LegacyTranscodeKind::PassThrough;
2394
2395 for source in sources {
2396 let normalized = normalize_legacy_parquet_columns(source).unwrap();
2397 let transcoded = transcode_legacy_record_batch_with_state(
2398 type_name,
2399 "legacy.parquet",
2400 normalized,
2401 &mut state,
2402 )
2403 .unwrap();
2404 transcode_kind = transcoded.kind;
2405 batches.extend(transcoded.batches);
2406 }
2407
2408 let fingerprint = schema_fingerprint(sources[0].schema_ref());
2409
2410 let file = PlannedMigrationFile {
2411 path: format!("data/{type_name}/legacy.parquet"),
2412 relative_path: format!("data/{type_name}/legacy.parquet"),
2413 source_type_name: type_name.to_string(),
2414 target_type_name: type_name.to_string(),
2415 target_table: type_name.to_string(),
2416 size: 1,
2417 e_tag: None,
2418 version: None,
2419 last_modified: String::new(),
2420 source_fingerprint: fingerprint.clone(),
2421 target_fingerprint: fingerprint,
2422 transcode_kind,
2423 };
2424
2425 prepare_migration_parts(&file, batches)
2426 }
2427
2428 fn assert_part_matches_fresh_write<T>(part: PreparedMigrationPart, expected: &[T])
2429 where
2430 T: Clone + DecodeFromRecordBatch + EncodeToRecordBatch + PartialEq + std::fmt::Debug,
2431 {
2432 let fresh = T::encode_batch(&T::chunk_metadata(expected), expected).unwrap();
2433
2434 let mut decoded = Vec::new();
2435
2436 for batch in part.batches {
2437 let schema = batch.schema();
2438 assert_eq!(schema, fresh.schema());
2439 decoded.extend(T::decode_batch(schema.metadata(), batch).unwrap());
2440 }
2441
2442 assert_eq!(part.row_count, expected.len());
2443 assert_eq!(decoded, expected);
2444 }
2445
2446 #[rstest]
2447 #[case::instrument_u64("CryptoPerpetual", DataType::UInt64, true)]
2448 #[case::instrument_utc("CryptoPerpetual", timestamp_data_type(), true)]
2449 #[case::instrument_no_timezone(
2450 "CryptoPerpetual",
2451 DataType::Timestamp(TimeUnit::Nanosecond, None),
2452 false
2453 )]
2454 #[case::instrument_other_timezone("CryptoPerpetual", DataType::Timestamp(TimeUnit::Nanosecond, Some("America/New_York".into())), false)]
2455 #[case::quote_u64("QuoteTick", DataType::UInt64, false)]
2456 #[case::quote_utc("QuoteTick", timestamp_data_type(), false)]
2457 fn legacy_instrument_detection_requires_known_class_and_supported_timestamps(
2458 #[case] class: &str,
2459 #[case] timestamp_type: DataType,
2460 #[case] expected: bool,
2461 ) {
2462 let schema = Schema::new_with_metadata(
2463 vec![Field::new("ts_init", timestamp_type, false)],
2464 HashMap::from([(LEGACY_KEY_CLASS.to_string(), class.to_string())]),
2465 );
2466
2467 assert_eq!(is_legacy_instrument_schema(&schema), expected);
2468 }
2469
2470 #[rstest]
2471 #[case::utc(Some("UTC"))]
2472 #[case::canonical(None)]
2473 fn v2_timestamp_normalization_matches_preflight_and_preserves_values(
2474 #[case] timezone: Option<&str>,
2475 ) {
2476 let first = QuoteTick {
2477 instrument_id: InstrumentId::from("AAPL.XNAS"),
2478 bid_price: Price::from("123.45"),
2479 ask_price: Price::from("123.67"),
2480 bid_size: Quantity::from(17),
2481 ask_size: Quantity::from(29),
2482 ts_event: 1_788_652_800_123_456_789_u64.into(),
2483 ts_init: 1_788_652_800_123_456_799_u64.into(),
2484 };
2485
2486 let values = vec![
2487 first,
2488 QuoteTick {
2489 ts_event: 1_788_652_800_123_456_801_u64.into(),
2490 ts_init: 1_788_652_800_123_456_899_u64.into(),
2491 ..first
2492 },
2493 ];
2494 let metadata = QuoteTick::get_metadata(&first.instrument_id, 2, 0);
2495 let expected = QuoteTick::encode_batch(&metadata, &values).unwrap();
2496 let source_type = DataType::Timestamp(TimeUnit::Nanosecond, timezone.map(Into::into));
2497
2498 let fields = expected
2499 .schema()
2500 .fields()
2501 .iter()
2502 .map(|field| {
2503 let field = field.as_ref().clone();
2504
2505 if matches!(field.data_type(), DataType::Timestamp(_, _)) {
2506 field.with_data_type(source_type.clone())
2507 } else {
2508 field
2509 }
2510 })
2511 .collect::<Vec<_>>();
2512
2513 let columns = expected
2514 .columns()
2515 .iter()
2516 .map(|column| {
2517 if let Some(timestamps) = column.as_any().downcast_ref::<TimestampNanosecondArray>()
2518 {
2519 Arc::new(
2520 timestamps
2521 .clone()
2522 .with_timezone_opt(timezone.map(Arc::<str>::from)),
2523 ) as ArrayRef
2524 } else {
2525 column.clone()
2526 }
2527 })
2528 .collect::<Vec<_>>();
2529
2530 let source = RecordBatch::try_new(
2531 Arc::new(Schema::new_with_metadata(
2532 fields,
2533 expected.schema().metadata().clone(),
2534 )),
2535 columns,
2536 )
2537 .unwrap();
2538 let preflight = normalize_legacy_parquet_schema(source.schema_ref());
2539 let normalized = normalize_legacy_parquet_columns(&source).unwrap();
2540 assert_eq!(&preflight, expected.schema_ref().as_ref());
2541 assert_eq!(normalized, expected);
2542 assert_eq!(
2543 QuoteTick::decode_batch(&metadata, normalized).unwrap(),
2544 values
2545 );
2546 }
2547
2548 #[rstest]
2549 fn timestamp_normalization_preserves_custom_nulls_and_unrelated_numeric_fields() {
2550 let schema = Arc::new(Schema::new_with_metadata(
2551 vec![
2552 Field::new(
2553 "ts_event",
2554 DataType::Timestamp(TimeUnit::Nanosecond, None),
2555 true,
2556 ),
2557 Field::new("ts_count", DataType::UInt64, false),
2558 ],
2559 HashMap::from([("type_name".to_string(), "TimestampSample".to_string())]),
2560 ));
2561 let timestamps =
2562 TimestampNanosecondArray::from(vec![Some(1_788_652_800_123_456_789), None]);
2563 let source = RecordBatch::try_new(
2564 schema,
2565 vec![
2566 Arc::new(timestamps),
2567 Arc::new(UInt64Array::from(vec![17, 29])),
2568 ],
2569 )
2570 .unwrap();
2571 let normalized = normalize_legacy_parquet_columns(&source).unwrap();
2572 assert_eq!(
2573 &normalize_legacy_parquet_schema(source.schema_ref()),
2574 normalized.schema_ref().as_ref()
2575 );
2576 assert_eq!(
2577 normalized.schema().field(0).data_type(),
2578 &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
2579 );
2580 assert_eq!(
2581 normalized
2582 .column(0)
2583 .as_any()
2584 .downcast_ref::<TimestampNanosecondArray>()
2585 .unwrap()
2586 .iter()
2587 .collect::<Vec<_>>(),
2588 vec![Some(1_788_652_800_123_456_789), None]
2589 );
2590 assert_eq!(normalized.column(1), source.column(1));
2591 }
2592
2593 #[rstest]
2594 fn normalize_legacy_info_schema_makes_binary_info_nullable() {
2595 let schema = Schema::new(vec![Field::new("info", DataType::Binary, false)]);
2596
2597 let normalized = normalize_legacy_parquet_schema(&schema);
2598
2599 let info = normalized.field_with_name("info").unwrap();
2600 assert_eq!(info.data_type(), &DataType::Utf8);
2601 assert!(info.is_nullable());
2602 }
2603
2604 #[rstest]
2605 fn normalize_dictionary_string_columns_casts_string_dictionaries_to_utf8() {
2606 let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2607 builder.append("AUD/USD.SIM").unwrap();
2608 builder.append("EUR/USD.SIM").unwrap();
2609 let dictionary = Arc::new(builder.finish()) as ArrayRef;
2610
2611 let schema = Arc::new(Schema::new(vec![
2612 Field::new("instrument_id", dictionary.data_type().clone(), false),
2613 Field::new("ts_init", DataType::UInt64, false),
2614 ]));
2615 let batch = RecordBatch::try_new(
2616 schema,
2617 vec![
2618 dictionary,
2619 Arc::new(UInt64Array::from(vec![1_u64, 2])) as ArrayRef,
2620 ],
2621 )
2622 .unwrap();
2623
2624 let normalized = normalize_dictionary_string_columns(&batch).unwrap();
2625
2626 assert_eq!(
2627 normalized
2628 .schema()
2629 .field_with_name("instrument_id")
2630 .unwrap()
2631 .data_type(),
2632 &DataType::Utf8,
2633 );
2634 let values = normalized
2635 .column_by_name("instrument_id")
2636 .unwrap()
2637 .as_any()
2638 .downcast_ref::<StringArray>()
2639 .unwrap()
2640 .iter()
2641 .collect::<Vec<_>>();
2642 assert_eq!(values, vec![Some("AUD/USD.SIM"), Some("EUR/USD.SIM")]);
2643 }
2644
2645 #[rstest]
2646 fn normalize_legacy_parquet_columns_preserves_unrecognized_dictionary() {
2647 let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2648 builder.append("alpha").unwrap();
2649 let dictionary = Arc::new(builder.finish()) as ArrayRef;
2650
2651 let schema = Arc::new(Schema::new(vec![Field::new(
2652 "label",
2653 dictionary.data_type().clone(),
2654 false,
2655 )]));
2656 let batch = RecordBatch::try_new(schema, vec![dictionary]).unwrap();
2657
2658 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2659
2660 assert_eq!(normalized, batch);
2661 }
2662
2663 #[rstest]
2664 #[case::without_ts_init(false)]
2665 #[case::timestamp_ts_init(true)]
2666 fn normalize_open_custom_columns_preserves_dictionary_with_type_metadata(
2667 #[case] has_ts_init: bool,
2668 ) {
2669 let mut builder = StringDictionaryBuilder::<Int8Type>::new();
2670 builder.append("alpha").unwrap();
2671 let dictionary = Arc::new(builder.finish()) as ArrayRef;
2672 let decimal = Arc::new(
2673 Decimal128Array::from(vec![Some(123_i128)])
2674 .with_precision_and_scale(38, 16)
2675 .unwrap(),
2676 ) as ArrayRef;
2677
2678 let mut fields = vec![
2679 Field::new("label", dictionary.data_type().clone(), false),
2680 Field::new("price", decimal.data_type().clone(), false),
2681 Field::new("ts_recv", DataType::UInt64, false),
2682 ];
2683 let mut columns = vec![dictionary, decimal, Arc::new(UInt64Array::from(vec![7]))];
2684
2685 if has_ts_init {
2686 fields.push(Field::new("ts_init", timestamp_data_type(), false));
2687 columns.push(Arc::new(timestamp_array([13]).unwrap()));
2688 }
2689
2690 let schema = Arc::new(Schema::new_with_metadata(
2691 fields,
2692 HashMap::from([("type_name".to_string(), "CustomData".to_string())]),
2693 ));
2694 let batch = RecordBatch::try_new(schema, columns).unwrap();
2695
2696 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2697 let normalized_schema = normalize_legacy_parquet_schema(batch.schema_ref());
2698
2699 assert_eq!(normalized, batch);
2700 assert_eq!(&normalized_schema, batch.schema_ref().as_ref());
2701 }
2702
2703 #[rstest]
2704 fn normalize_legacy_parquet_schema_preserves_unrecognized_fixed_binary() {
2705 let schema = Schema::new(vec![Field::new(
2706 "price",
2707 DataType::FixedSizeBinary(8),
2708 false,
2709 )]);
2710
2711 let normalized = normalize_legacy_parquet_schema(&schema);
2712
2713 assert_eq!(normalized, schema);
2714 }
2715
2716 #[rstest]
2717 fn normalize_legacy_parquet_columns_converts_binary_info_null_to_arrow_null() {
2718 let schema = Arc::new(Schema::new(vec![
2719 Field::new("info", DataType::Binary, true),
2720 Field::new("ts_init", DataType::UInt64, false),
2721 ]));
2722 let batch = RecordBatch::try_new(
2723 schema,
2724 vec![
2725 Arc::new(BinaryArray::from_vec(vec![b"null".as_slice()])) as ArrayRef,
2726 Arc::new(UInt64Array::from(vec![1_u64])) as ArrayRef,
2727 ],
2728 )
2729 .unwrap();
2730
2731 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2732 let info = normalized
2733 .column_by_name("info")
2734 .unwrap()
2735 .as_any()
2736 .downcast_ref::<StringArray>()
2737 .unwrap();
2738
2739 assert_eq!(
2740 normalized
2741 .schema()
2742 .field_with_name("info")
2743 .unwrap()
2744 .data_type(),
2745 &DataType::Utf8,
2746 );
2747 assert!(info.is_null(0));
2748 }
2749
2750 #[rstest]
2751 fn normalize_legacy_parquet_columns_preserves_quote_price_columns() {
2752 let decimal = DataType::Decimal128(38, 16);
2753
2754 let schema = Arc::new(Schema::new(vec![
2755 Field::new("bid_price", decimal.clone(), false),
2756 Field::new("ask_price", decimal.clone(), false),
2757 Field::new("bid_size", decimal.clone(), false),
2758 Field::new("ask_size", decimal, false),
2759 ]));
2760
2761 let values = || {
2762 Arc::new(
2763 Decimal128Array::from(vec![1_i128])
2764 .with_precision_and_scale(38, 16)
2765 .unwrap(),
2766 ) as ArrayRef
2767 };
2768
2769 let batch =
2770 RecordBatch::try_new(schema.clone(), vec![values(), values(), values(), values()])
2771 .unwrap();
2772
2773 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2774
2775 assert_eq!(normalized.schema(), schema);
2776 assert_eq!(normalized, batch);
2777 }
2778
2779 #[rstest]
2780 fn normalize_legacy_depth_flat_columns_builds_structured_sides() {
2781 let mut fields = Vec::new();
2782 let mut columns = Vec::new();
2783
2784 for side in ["bid", "ask"] {
2785 for level in 0..DEPTH10_LEN {
2786 for (name, value) in [("price", 11_i128), ("size", 22_i128)] {
2787 fields.push(Field::new(
2788 format!("{side}_{name}_{level}"),
2789 DataType::Decimal128(38, 16),
2790 true,
2791 ));
2792 let value = (level == 0).then_some(value);
2793 columns.push(Arc::new(
2794 Decimal128Array::from(vec![value])
2795 .with_precision_and_scale(38, 16)
2796 .unwrap(),
2797 ) as ArrayRef);
2798 }
2799
2800 fields.push(Field::new(
2801 format!("{side}_count_{level}"),
2802 DataType::UInt32,
2803 false,
2804 ));
2805 columns.push(Arc::new(UInt32Array::from(vec![33])) as ArrayRef);
2806 fields.push(Field::new(
2807 format!("{side}_order_id_{level}"),
2808 DataType::UInt64,
2809 false,
2810 ));
2811 columns.push(Arc::new(UInt64Array::from(vec![44])) as ArrayRef);
2812 }
2813 }
2814
2815 let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2816
2817 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2818
2819 assert_eq!(normalized.num_columns(), 2);
2820 assert_normalized_depth(&normalized, 1, 11, 22, 33, 44);
2821 }
2822
2823 #[rstest]
2824 fn normalize_legacy_depth_fixed_lists_builds_structured_sides() {
2825 let decimal_values = |value| {
2826 Arc::new(
2827 Decimal128Array::from(
2828 (0..DEPTH10_LEN)
2829 .map(|level| (level == 0).then_some(value))
2830 .collect::<Vec<_>>(),
2831 )
2832 .with_precision_and_scale(38, 16)
2833 .unwrap(),
2834 ) as ArrayRef
2835 };
2836
2837 let counts = Arc::new(UInt32Array::from(vec![33; DEPTH10_LEN])) as ArrayRef;
2838 let order_ids = Arc::new(UInt64Array::from(vec![44; DEPTH10_LEN])) as ArrayRef;
2839 let mut fields = Vec::new();
2840 let mut columns = Vec::new();
2841
2842 for side in ["bid", "ask"] {
2843 for (name, column) in [
2844 ("price", depth_list_array(decimal_values(11), true)),
2845 ("size", depth_list_array(decimal_values(22), true)),
2846 ("count", depth_list_array(counts.clone(), false)),
2847 ("order_id", depth_list_array(order_ids.clone(), false)),
2848 ] {
2849 fields.push(Field::new(
2850 format!("{side}_{name}"),
2851 column.data_type().clone(),
2852 false,
2853 ));
2854 columns.push(column);
2855 }
2856 }
2857
2858 let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2859
2860 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2861
2862 assert_eq!(normalized.num_columns(), 2);
2863 assert_normalized_depth(&normalized, 1, 11, 22, 33, 44);
2864 }
2865
2866 #[rstest]
2867 fn normalize_legacy_depth_missing_counts_and_order_ids_uses_list_width() {
2868 const WIDTH: i32 = 3;
2869
2870 let decimal_values = |value| {
2871 Arc::new(
2872 Decimal128Array::from(vec![value; WIDTH as usize])
2873 .with_precision_and_scale(38, 16)
2874 .unwrap(),
2875 ) as ArrayRef
2876 };
2877
2878 let list = |values: ArrayRef| {
2879 Arc::new(FixedSizeListArray::new(
2880 Arc::new(Field::new("item", values.data_type().clone(), false)),
2881 WIDTH,
2882 values,
2883 None,
2884 )) as ArrayRef
2885 };
2886
2887 let mut fields = Vec::new();
2888 let mut columns = Vec::new();
2889
2890 for side in ["bid", "ask"] {
2891 for (name, column) in [
2892 ("price", list(decimal_values(11))),
2893 ("size", list(decimal_values(22))),
2894 ] {
2895 fields.push(Field::new(
2896 format!("{side}_{name}"),
2897 column.data_type().clone(),
2898 false,
2899 ));
2900 columns.push(column);
2901 }
2902 }
2903
2904 let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2905
2906 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
2907
2908 assert_eq!(normalized.num_columns(), 2);
2909 assert_normalized_depth(&normalized, WIDTH as usize, 11, 22, 0, 0);
2910 }
2911
2912 #[rstest]
2913 #[case::with_order_ids(true, 44)]
2914 #[case::without_order_ids(false, 0)]
2915 fn normalize_legacy_depth_fixed_binary_lists_matches_schema(
2916 #[case] include_order_ids: bool,
2917 #[case] expected_order_id: u64,
2918 ) {
2919 let fixed_values = |value: [u8; 8]| {
2920 let values = (0..DEPTH10_LEN)
2921 .map(|level| (level == 0).then_some(value))
2922 .collect::<Vec<_>>();
2923 Arc::new(
2924 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2925 values
2926 .iter()
2927 .map(Option::as_ref)
2928 .map(|value| value.map(<[u8; 8]>::as_slice)),
2929 8,
2930 )
2931 .unwrap(),
2932 ) as ArrayRef
2933 };
2934
2935 let counts = Arc::new(UInt32Array::from(vec![33; DEPTH10_LEN])) as ArrayRef;
2936 let order_ids = Arc::new(UInt64Array::from(vec![44; DEPTH10_LEN])) as ArrayRef;
2937 let mut fields = Vec::new();
2938 let mut columns = Vec::new();
2939
2940 for side in ["bid", "ask"] {
2941 let mut side_columns = vec![
2942 (
2943 "price",
2944 depth_list_array(fixed_values(11_i64.to_le_bytes()), true),
2945 ),
2946 (
2947 "size",
2948 depth_list_array(fixed_values(22_u64.to_le_bytes()), true),
2949 ),
2950 ("count", depth_list_array(counts.clone(), false)),
2951 ];
2952
2953 if include_order_ids {
2954 side_columns.push(("order_id", depth_list_array(order_ids.clone(), false)));
2955 }
2956
2957 for (name, column) in side_columns {
2958 fields.push(Field::new(
2959 format!("{side}_{name}"),
2960 column.data_type().clone(),
2961 false,
2962 ));
2963 columns.push(column);
2964 }
2965 }
2966
2967 let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
2968
2969 assert!(is_nautilus_legacy_schema(batch.schema_ref()));
2970 let normalized_schema = normalize_legacy_parquet_schema(batch.schema_ref());
2971 let normalized_batch = normalize_legacy_parquet_columns(&batch).unwrap();
2972
2973 assert_eq!(
2974 normalized_schema,
2975 normalized_batch.schema().as_ref().clone()
2976 );
2977 assert_normalized_depth(
2978 &normalized_batch,
2979 1,
2980 110_000_000,
2981 220_000_000,
2982 33,
2983 expected_order_id,
2984 );
2985 }
2986
2987 #[rstest]
2988 fn normalize_legacy_depth_flat_fixed_columns_preserves_order_ids() {
2989 let fixed_price = || {
2990 let bytes = 11_i64.to_le_bytes();
2991 Arc::new(
2992 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2993 [Some(bytes.as_slice())].into_iter(),
2994 8,
2995 )
2996 .unwrap(),
2997 ) as ArrayRef
2998 };
2999
3000 let fixed_size = || {
3001 let bytes = 22_u64.to_le_bytes();
3002 Arc::new(
3003 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
3004 [Some(bytes.as_slice())].into_iter(),
3005 8,
3006 )
3007 .unwrap(),
3008 ) as ArrayRef
3009 };
3010
3011 let mut fields = Vec::new();
3012 let mut columns = Vec::new();
3013
3014 for side in ["bid", "ask"] {
3015 for level in 0..DEPTH10_LEN {
3016 fields.push(Field::new(
3017 format!("{side}_price_{level}"),
3018 DataType::FixedSizeBinary(8),
3019 false,
3020 ));
3021 columns.push(fixed_price());
3022 fields.push(Field::new(
3023 format!("{side}_size_{level}"),
3024 DataType::FixedSizeBinary(8),
3025 false,
3026 ));
3027 columns.push(fixed_size());
3028 fields.push(Field::new(
3029 format!("{side}_count_{level}"),
3030 DataType::UInt32,
3031 false,
3032 ));
3033 columns.push(Arc::new(UInt32Array::from(vec![33])) as ArrayRef);
3034 fields.push(Field::new(
3035 format!("{side}_order_id_{level}"),
3036 DataType::UInt64,
3037 false,
3038 ));
3039 columns.push(Arc::new(UInt64Array::from(vec![44])) as ArrayRef);
3040 }
3041 }
3042
3043 for (field, column) in [
3044 (
3045 Field::new("flags", DataType::UInt8, false),
3046 Arc::new(UInt8Array::from(vec![0])) as ArrayRef,
3047 ),
3048 (
3049 Field::new("sequence", DataType::UInt64, false),
3050 Arc::new(UInt64Array::from(vec![1])) as ArrayRef,
3051 ),
3052 (
3053 Field::new("ts_event", DataType::UInt64, false),
3054 Arc::new(UInt64Array::from(vec![2])) as ArrayRef,
3055 ),
3056 (
3057 Field::new("ts_init", DataType::UInt64, false),
3058 Arc::new(UInt64Array::from(vec![3])) as ArrayRef,
3059 ),
3060 ] {
3061 fields.push(field);
3062 columns.push(column);
3063 }
3064
3065 let batch = RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).unwrap();
3066 assert!(is_nautilus_legacy_schema(batch.schema_ref()));
3067
3068 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3069
3070 for side in ["bids", "asks"] {
3071 let list = normalized
3072 .column_by_name(side)
3073 .unwrap()
3074 .as_any()
3075 .downcast_ref::<ListArray>()
3076 .unwrap();
3077 let levels = list.value(0);
3078 let levels = levels.as_any().downcast_ref::<StructArray>().unwrap();
3079 let order_ids = levels
3080 .column_by_name("order_id")
3081 .unwrap()
3082 .as_any()
3083 .downcast_ref::<UInt64Array>()
3084 .unwrap();
3085 assert_eq!(order_ids.values(), &[44; DEPTH10_LEN]);
3086 }
3087 }
3088
3089 #[rstest]
3090 fn normalize_legacy_depth_fixture_matches_open_shape() {
3091 let precision_dir = if cfg!(feature = "high-precision") {
3092 "128-bit"
3093 } else {
3094 "64-bit"
3095 };
3096
3097 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3098 .join("../../test_data/nautilus/legacy")
3099 .join(precision_dir)
3100 .join("depths.parquet");
3101 let file = std::fs::File::open(path).unwrap();
3102 let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
3103 let metadata = builder
3104 .metadata()
3105 .file_metadata()
3106 .key_value_metadata()
3107 .unwrap();
3108
3109 for (key, value) in [
3110 ("instrument_id", "AAPL.XNAS"),
3111 ("price_precision", "4"),
3112 ("size_precision", "1"),
3113 ] {
3114 assert_eq!(
3115 metadata
3116 .iter()
3117 .find(|entry| entry.key == key)
3118 .and_then(|entry| entry.value.as_deref()),
3119 Some(value),
3120 );
3121 }
3122
3123 let batch = builder.build().unwrap().next().unwrap().unwrap();
3124
3125 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3126
3127 assert_normalized_depth(
3128 &normalized,
3129 DEPTH10_LEN,
3130 12_345_000_000_000_000,
3131 25_000_000_000_000_000,
3132 3,
3133 0,
3134 );
3135 assert_eq!(normalized.num_columns(), 6);
3136 assert_eq!(
3137 normalized
3138 .column_by_name("flags")
3139 .unwrap()
3140 .as_any()
3141 .downcast_ref::<UInt8Array>()
3142 .unwrap()
3143 .value(0),
3144 32
3145 );
3146 assert_eq!(
3147 normalized
3148 .column_by_name("sequence")
3149 .unwrap()
3150 .as_any()
3151 .downcast_ref::<UInt64Array>()
3152 .unwrap()
3153 .value(0),
3154 7
3155 );
3156
3157 for name in ["ts_event", "ts_init"] {
3158 assert_eq!(
3159 normalized
3160 .schema()
3161 .field_with_name(name)
3162 .unwrap()
3163 .data_type(),
3164 &DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, Some("UTC".into())),
3165 );
3166 }
3167 }
3168
3169 #[rstest]
3170 #[case("quotes.parquet", "bid_price", None)]
3171 #[case("trades.parquet", "price", Some("aggressor_side"))]
3172 #[case("bars.parquet", "open", None)]
3173 #[case("deltas.parquet", "price", Some("action"))]
3174 fn legacy_market_fixture_matches_open_types(
3175 #[case] file_name: &str,
3176 #[case] fixed_field: &str,
3177 #[case] enum_field: Option<&str>,
3178 ) {
3179 let precision_dir = if cfg!(feature = "high-precision") {
3180 "128-bit"
3181 } else {
3182 "64-bit"
3183 };
3184
3185 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3186 .join("../../test_data/nautilus/legacy")
3187 .join(precision_dir)
3188 .join(file_name);
3189 let file = std::fs::File::open(path).unwrap();
3190 let batch = ParquetRecordBatchReaderBuilder::try_new(file)
3191 .unwrap()
3192 .build()
3193 .unwrap()
3194 .next()
3195 .unwrap()
3196 .unwrap();
3197
3198 let normalized = normalize_legacy_parquet_columns(&batch).unwrap();
3199
3200 assert_eq!(
3201 normalized
3202 .schema()
3203 .field_with_name(fixed_field)
3204 .unwrap()
3205 .data_type(),
3206 &DataType::Decimal128(38, 16),
3207 );
3208 assert_eq!(
3209 normalized
3210 .schema()
3211 .field_with_name("ts_init")
3212 .unwrap()
3213 .data_type(),
3214 &DataType::Timestamp(arrow::datatypes::TimeUnit::Nanosecond, Some("UTC".into())),
3215 );
3216
3217 if let Some(enum_field) = enum_field {
3218 assert!(matches!(
3219 normalized
3220 .schema()
3221 .field_with_name(enum_field)
3222 .unwrap()
3223 .data_type(),
3224 DataType::Dictionary(_, value) if value.as_ref() == &DataType::Utf8
3225 ));
3226 }
3227 }
3228
3229 #[rstest]
3230 #[case("depths.parquet")]
3231 #[case("quotes.parquet")]
3232 #[case("trades.parquet")]
3233 #[case("bars.parquet")]
3234 #[case("deltas.parquet")]
3235 #[case("dictionary-trade")]
3236 fn legacy_fixture_schema_normalization_matches_batch(#[case] file_name: &str) {
3237 let (schema, batch) = if file_name == "dictionary-trade" {
3238 let dictionary = |value: &str| {
3239 let mut builder = StringDictionaryBuilder::<Int8Type>::new();
3240 builder.append(value).unwrap();
3241 Arc::new(builder.finish()) as ArrayRef
3242 };
3243
3244 let price = 11_i64.to_le_bytes();
3245 let size = 22_u64.to_le_bytes();
3246 let trade_ids = dictionary("trade-1");
3247 let identifiers = dictionary("AAPL.XNAS");
3248
3249 let schema = Arc::new(Schema::new_with_metadata(
3250 vec![
3251 Field::new("price", DataType::FixedSizeBinary(8), false),
3252 Field::new("size", DataType::FixedSizeBinary(8), false),
3253 Field::new("aggressor_side", DataType::UInt8, false),
3254 Field::new("trade_id", trade_ids.data_type().clone(), false),
3255 Field::new("ts_event", DataType::UInt64, false),
3256 Field::new("ts_init", DataType::UInt64, false),
3257 Field::new(KEY_IDENTIFIER, identifiers.data_type().clone(), false),
3258 ],
3259 HashMap::from([("type".to_string(), "TradeTick".to_string())]),
3260 ));
3261 let batch = RecordBatch::try_new(
3262 Arc::clone(&schema),
3263 vec![
3264 Arc::new(
3265 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
3266 [Some(price.as_slice())].into_iter(),
3267 8,
3268 )
3269 .unwrap(),
3270 ),
3271 Arc::new(
3272 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
3273 [Some(size.as_slice())].into_iter(),
3274 8,
3275 )
3276 .unwrap(),
3277 ),
3278 Arc::new(UInt8Array::from(vec![1])),
3279 trade_ids,
3280 Arc::new(UInt64Array::from(vec![1])),
3281 Arc::new(UInt64Array::from(vec![2])),
3282 identifiers,
3283 ],
3284 )
3285 .unwrap();
3286 (schema, batch)
3287 } else {
3288 let precision_dir = if cfg!(feature = "high-precision") {
3289 "128-bit"
3290 } else {
3291 "64-bit"
3292 };
3293
3294 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3295 .join("../../test_data/nautilus/legacy")
3296 .join(precision_dir)
3297 .join(file_name);
3298 let file = std::fs::File::open(path).unwrap();
3299 let builder = ParquetRecordBatchReaderBuilder::try_new(file).unwrap();
3300 let schema = builder.schema().clone();
3301 let batch = builder.build().unwrap().next().unwrap().unwrap();
3302 let batch =
3303 RecordBatch::try_new(Arc::clone(&schema), batch.columns().to_vec()).unwrap();
3304 (schema, batch)
3305 };
3306
3307 let normalized_schema = normalize_legacy_parquet_schema(schema.as_ref());
3308 let normalized_batch = normalize_legacy_parquet_columns(&batch).unwrap();
3309
3310 assert_eq!(
3311 normalized_schema,
3312 normalized_batch.schema().as_ref().clone()
3313 );
3314
3315 if file_name == "dictionary-trade" {
3316 assert_eq!(
3317 normalized_batch
3318 .schema()
3319 .field_with_name("trade_id")
3320 .unwrap()
3321 .data_type(),
3322 &DataType::Utf8,
3323 );
3324 }
3325 }
3326
3327 fn assert_normalized_depth(
3328 batch: &RecordBatch,
3329 level_count: usize,
3330 price: i128,
3331 size: i128,
3332 count: u32,
3333 order_id: u64,
3334 ) {
3335 let schema = batch.schema();
3336 assert_eq!(schema.field(0).name(), "bids");
3337 assert_eq!(schema.field(1).name(), "asks");
3338
3339 for side in ["bids", "asks"] {
3340 let list = batch
3341 .column_by_name(side)
3342 .unwrap()
3343 .as_any()
3344 .downcast_ref::<ListArray>()
3345 .unwrap();
3346 let levels = list.value(0);
3347 let levels = levels.as_any().downcast_ref::<StructArray>().unwrap();
3348 let prices = levels
3349 .column_by_name("price")
3350 .unwrap()
3351 .as_any()
3352 .downcast_ref::<Decimal128Array>()
3353 .unwrap();
3354 let sizes = levels
3355 .column_by_name("size")
3356 .unwrap()
3357 .as_any()
3358 .downcast_ref::<Decimal128Array>()
3359 .unwrap();
3360 let counts = levels
3361 .column_by_name("count")
3362 .unwrap()
3363 .as_any()
3364 .downcast_ref::<UInt32Array>()
3365 .unwrap();
3366 let order_ids = levels
3367 .column_by_name("order_id")
3368 .unwrap()
3369 .as_any()
3370 .downcast_ref::<UInt64Array>()
3371 .unwrap();
3372
3373 assert_eq!(levels.len(), level_count);
3374 assert_eq!(prices.value(0), price);
3375 assert_eq!(sizes.value(0), size);
3376 assert_eq!(counts.value(0), count);
3377 assert_eq!(order_ids.value(0), order_id);
3378 }
3379 }
3380}