Skip to main content

nautilus_serialization/arrow/
legacy.rs

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