Skip to main content

nautilus_serialization/arrow/
catalog_display.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//! Converts raw catalog Arrow batches into display-friendly Arrow batches.
17//!
18//! Catalog batches store prices and quantities as scale-16 decimals plus schema metadata. This
19//! module preserves the raw catalog query path while converting the values to named `Float64`
20//! columns suitable for PyArrow/Polars. Migrate legacy catalog files before requesting display output.
21
22use std::{collections::HashMap, str::FromStr, sync::Arc};
23
24use arrow::{
25    array::{
26        Array, ArrayRef, BooleanArray, Decimal128Array, Float64Array, Float64Builder, Int8Array,
27        Int16Array, ListArray, StringArray, StringBuilder, StructArray, TimestampNanosecondArray,
28        TimestampNanosecondBuilder, UInt8Array, UInt8Builder, UInt32Array, UInt64Array,
29        UInt64Builder,
30    },
31    compute::cast,
32    datatypes::{DataType, Field, Schema},
33    record_batch::RecordBatch,
34};
35use nautilus_model::{
36    data::{
37        Bar, BarType, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
38        MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
39        TradeTick, get_arrow_schema,
40    },
41    enums::{AggressorSide, BookAction, InstrumentCloseType, OrderSide},
42    instruments::InstrumentAny,
43    types::{Price, Quantity},
44};
45use rust_decimal::{Decimal, prelude::ToPrimitive};
46
47use super::{
48    ArrowSchemaProvider, EncodingError, KEY_BAR_TYPE, KEY_IDENTIFIER, KEY_INSTRUMENT_ID,
49    KEY_PRICE_PRECISION, KEY_SIZE_PRECISION, U64ColumnRef,
50    custom::CustomDataDecoder,
51    decode_decimal_price, decode_decimal_quantity,
52    depth_display::{DepthSideBuilder, schema as depth_schema},
53    display_conversion::{
54        float64_field, price_to_f64, quantity_to_f64, timestamp_field, utf8_field,
55    },
56    extract_column, extract_column_string, fixed_decimal_data_type,
57};
58use crate::arrow::timestamp_data_type;
59
60struct CatalogDisplayFns {
61    schema: fn() -> Schema,
62    convert: fn(&HashMap<String, String>, &RecordBatch) -> Result<RecordBatch, EncodingError>,
63}
64
65trait CatalogDisplay {
66    const FUNCTIONS: Option<CatalogDisplayFns>;
67}
68
69impl CatalogDisplay for InstrumentAny {
70    const FUNCTIONS: Option<CatalogDisplayFns> = None;
71}
72
73impl CatalogDisplay for QuoteTick {
74    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
75        schema: quotes_schema,
76        convert: convert_quotes,
77    });
78}
79
80impl CatalogDisplay for TradeTick {
81    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
82        schema: trades_schema,
83        convert: convert_trades,
84    });
85}
86
87impl CatalogDisplay for Bar {
88    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
89        schema: bars_schema,
90        convert: convert_bars,
91    });
92}
93
94impl CatalogDisplay for OrderBookDelta {
95    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
96        schema: deltas_schema,
97        convert: convert_deltas,
98    });
99}
100
101impl CatalogDisplay for OrderBookDepth {
102    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
103        schema: depth_schema,
104        convert: convert_depths,
105    });
106}
107
108impl CatalogDisplay for MarkPriceUpdate {
109    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
110        schema: price_update_schema,
111        convert: convert_price_updates,
112    });
113}
114
115impl CatalogDisplay for IndexPriceUpdate {
116    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
117        schema: price_update_schema,
118        convert: convert_price_updates,
119    });
120}
121
122impl CatalogDisplay for FundingRateUpdate {
123    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
124        schema: funding_rate_schema,
125        convert: convert_funding_rates_with_metadata,
126    });
127}
128
129impl CatalogDisplay for InstrumentStatus {
130    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
131        schema: instrument_status_schema,
132        convert: convert_instrument_status_with_metadata,
133    });
134}
135
136impl CatalogDisplay for OptionGreeks {
137    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
138        schema: option_greeks_schema,
139        convert: convert_option_greeks_with_metadata,
140    });
141}
142
143impl CatalogDisplay for InstrumentClose {
144    const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
145        schema: instrument_closes_schema,
146        convert: convert_instrument_closes,
147    });
148}
149
150macro_rules! define_catalog_display_lookup {
151    ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
152        fn builtin_catalog_display(data_type: &NautilusDataType) -> Option<CatalogDisplayFns> {
153            match data_type {
154                $(
155                    NautilusDataType::$variant => <$type as CatalogDisplay>::FUNCTIONS,
156                )+
157                _ => None,
158            }
159        }
160    };
161}
162
163nautilus_model::for_each_data_type!(define_catalog_display_lookup);
164
165macro_rules! define_catalog_raw_schema_lookup {
166    (
167        ($_instrument_variant:ident, $_instrument_type:ident, $_instrument_data:ident, $_instrument_batch:ident, $_instrument_prefix:literal),
168        $(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?
169    ) => {
170        fn builtin_catalog_raw_schema(data_type: &NautilusDataType) -> Option<Schema> {
171            match data_type {
172                $(
173                    NautilusDataType::$variant => {
174                        Some(<$type as ArrowSchemaProvider>::get_schema(None))
175                    }
176                )+
177                _ => None,
178            }
179        }
180    };
181}
182
183nautilus_model::for_each_data_type!(define_catalog_raw_schema_lookup);
184
185/// Returns the raw Arrow schema for a catalog data type.
186///
187/// # Errors
188///
189/// Returns [`EncodingError::ParseError`] when the data type is unsupported.
190pub fn catalog_raw_schema(data_type: &NautilusDataType) -> Result<Schema, EncodingError> {
191    if let NautilusDataType::Custom { type_name } = data_type {
192        if get_arrow_schema(type_name).is_none() {
193            return Err(unsupported_display_type(data_type));
194        }
195        let metadata = HashMap::from([("type_name".to_string(), type_name.clone())]);
196        return Ok(super::schema_with_identifier_column(
197            &CustomDataDecoder::get_schema(Some(metadata)),
198        ));
199    }
200
201    builtin_catalog_raw_schema(data_type).ok_or_else(|| unsupported_display_type(data_type))
202}
203
204/// Returns a display-friendly Arrow schema for a catalog data type.
205///
206/// # Errors
207///
208/// Returns [`EncodingError::ParseError`] when the data type is unsupported.
209#[allow(
210    clippy::match_wildcard_for_single_variants,
211    unreachable_patterns,
212    reason = "NautilusDataType::Defi is controlled by nautilus-model features, not this crate"
213)]
214pub fn catalog_display_schema(data_type: &NautilusDataType) -> Result<Schema, EncodingError> {
215    if matches!(data_type, NautilusDataType::Custom { .. }) {
216        let schema = catalog_raw_schema(data_type)?;
217        let batch = RecordBatch::new_empty(Arc::new(schema));
218        return catalog_record_batch_to_display(data_type, batch.schema().metadata(), &batch)
219            .map(|batch| batch.schema().as_ref().clone());
220    }
221
222    builtin_catalog_display(data_type)
223        .map(|functions| (functions.schema)())
224        .ok_or_else(|| unsupported_display_type(data_type))
225}
226
227/// Converts a raw catalog record batch into display Arrow without decoding full data objects.
228///
229/// # Errors
230///
231/// Returns [`EncodingError`] when required metadata or columns are missing, a
232/// columns do not use the current Arrow format, or enum values are invalid.
233#[allow(
234    clippy::match_wildcard_for_single_variants,
235    unreachable_patterns,
236    reason = "NautilusDataType::Defi is controlled by nautilus-model features, not this crate"
237)]
238pub fn catalog_record_batch_to_display(
239    data_type: &NautilusDataType,
240    metadata: &HashMap<String, String>,
241    record_batch: &RecordBatch,
242) -> Result<RecordBatch, EncodingError> {
243    let display_batch = if matches!(data_type, NautilusDataType::Custom { .. }) {
244        convert_custom(record_batch)
245    } else if let Some(functions) = builtin_catalog_display(data_type) {
246        (functions.convert)(metadata, record_batch)
247    } else {
248        Err(unsupported_display_type(data_type))
249    }?;
250
251    append_identifier_column_if_present(display_batch, record_batch)
252}
253
254fn unsupported_display_type(data_type: &NautilusDataType) -> EncodingError {
255    EncodingError::ParseError(
256        "data_type",
257        format!("unsupported display catalog data type `{data_type}`"),
258    )
259}
260
261fn convert_funding_rates_with_metadata(
262    _: &HashMap<String, String>,
263    batch: &RecordBatch,
264) -> Result<RecordBatch, EncodingError> {
265    convert_funding_rates(batch)
266}
267
268fn convert_option_greeks_with_metadata(
269    _: &HashMap<String, String>,
270    batch: &RecordBatch,
271) -> Result<RecordBatch, EncodingError> {
272    convert_option_greeks(batch)
273}
274
275fn convert_instrument_status_with_metadata(
276    _: &HashMap<String, String>,
277    batch: &RecordBatch,
278) -> Result<RecordBatch, EncodingError> {
279    convert_instrument_status(batch)
280}
281
282fn append_identifier_column_if_present(
283    display_batch: RecordBatch,
284    catalog_batch: &RecordBatch,
285) -> Result<RecordBatch, EncodingError> {
286    if display_batch.schema().index_of(KEY_IDENTIFIER).is_ok() {
287        return Ok(display_batch);
288    }
289
290    let Ok(identifier_index) = catalog_batch.schema().index_of(KEY_IDENTIFIER) else {
291        return Ok(display_batch);
292    };
293
294    let catalog_schema = catalog_batch.schema();
295    let identifier_field = catalog_schema.field(identifier_index);
296    let (identifier_field, identifier_column) =
297        if identifier_field.data_type() == &DataType::Utf8View {
298            (
299                Field::new(
300                    identifier_field.name(),
301                    DataType::Utf8,
302                    identifier_field.is_nullable(),
303                ),
304                cast(
305                    catalog_batch.column(identifier_index).as_ref(),
306                    &DataType::Utf8,
307                )?,
308            )
309        } else {
310            (
311                identifier_field.clone(),
312                catalog_batch.column(identifier_index).clone(),
313            )
314        };
315
316    let mut fields = display_batch.schema().fields().to_vec();
317    fields.push(Arc::new(identifier_field));
318    let mut columns = display_batch.columns().to_vec();
319    columns.push(identifier_column);
320
321    RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).map_err(EncodingError::from)
322}
323
324fn parse_price_precision(metadata: &HashMap<String, String>) -> Result<u8, EncodingError> {
325    parse_precision(metadata, KEY_PRICE_PRECISION)
326}
327
328fn parse_size_precision(metadata: &HashMap<String, String>) -> Result<u8, EncodingError> {
329    parse_precision(metadata, KEY_SIZE_PRECISION)
330}
331
332fn parse_precision(
333    metadata: &HashMap<String, String>,
334    key: &'static str,
335) -> Result<u8, EncodingError> {
336    metadata
337        .get(key)
338        .ok_or(EncodingError::MissingMetadata(key))?
339        .parse::<u8>()
340        .map_err(|e| EncodingError::ParseError(key, e.to_string()))
341}
342
343fn instrument_id(metadata: &HashMap<String, String>) -> Result<&str, EncodingError> {
344    metadata
345        .get(KEY_INSTRUMENT_ID)
346        .map(String::as_str)
347        .ok_or_else(|| EncodingError::MissingMetadata(KEY_INSTRUMENT_ID))
348}
349
350fn bar_type(metadata: &HashMap<String, String>) -> Result<BarType, EncodingError> {
351    let value = metadata
352        .get(KEY_BAR_TYPE)
353        .ok_or_else(|| EncodingError::MissingMetadata(KEY_BAR_TYPE))?;
354    BarType::from_str(value).map_err(|e| EncodingError::ParseError(KEY_BAR_TYPE, e.to_string()))
355}
356
357fn constant_string_column(value: &str, len: usize) -> ArrayRef {
358    Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
359        value, len,
360    )))
361}
362
363struct FixedPrecisionColumn<'a>(&'a Decimal128Array);
364
365impl FixedPrecisionColumn<'_> {
366    fn len(&self) -> usize {
367        self.0.len()
368    }
369    fn is_null(&self, row: usize) -> bool {
370        self.0.is_null(row)
371    }
372    fn price(
373        &self,
374        precision: u8,
375        field: &'static str,
376        row: usize,
377    ) -> Result<Price, EncodingError> {
378        decode_decimal_price(self.0, precision, field, row)
379    }
380    fn quantity(
381        &self,
382        precision: u8,
383        field: &'static str,
384        row: usize,
385    ) -> Result<Quantity, EncodingError> {
386        decode_decimal_quantity(self.0, precision, field, row)
387    }
388}
389
390struct NanosColumn<'a>(&'a TimestampNanosecondArray);
391
392impl NanosColumn<'_> {
393    fn len(&self) -> usize {
394        self.0.len()
395    }
396    fn is_null(&self, row: usize) -> bool {
397        self.0.is_null(row)
398    }
399    fn value(&self, row: usize) -> i64 {
400        self.0.value(row)
401    }
402}
403
404enum U8Column<'a> {
405    UInt8(&'a UInt8Array),
406    Int8(&'a Int8Array),
407    Int16(&'a Int16Array),
408}
409
410impl U8Column<'_> {
411    fn value(&self, row: usize, field: &'static str) -> Result<u8, EncodingError> {
412        match self {
413            Self::UInt8(values) => Ok(values.value(row)),
414            Self::Int8(values) => u8::try_from(values.value(row)).map_err(|_| {
415                EncodingError::ParseError(field, format!("Invalid negative value at row {row}"))
416            }),
417            Self::Int16(values) => u8::try_from(values.value(row)).map_err(|_| {
418                EncodingError::ParseError(field, format!("Value out of u8 range at row {row}"))
419            }),
420        }
421    }
422}
423
424fn fixed_col<'a>(
425    batch: &'a RecordBatch,
426    name: &'static str,
427) -> Result<FixedPrecisionColumn<'a>, EncodingError> {
428    Ok(FixedPrecisionColumn(super::extract_decimal_column(
429        batch, name,
430    )?))
431}
432
433fn u8_col<'a>(batch: &'a RecordBatch, name: &'static str) -> Result<U8Column<'a>, EncodingError> {
434    let index = batch.schema().index_of(name)?;
435    let column = batch.column(index);
436    match column.data_type() {
437        DataType::UInt8 => Ok(U8Column::UInt8(extract_column::<UInt8Array>(
438            batch.columns(),
439            name,
440            index,
441            DataType::UInt8,
442        )?)),
443        DataType::Int8 => Ok(U8Column::Int8(extract_column::<Int8Array>(
444            batch.columns(),
445            name,
446            index,
447            DataType::Int8,
448        )?)),
449        DataType::Int16 => Ok(U8Column::Int16(extract_column::<Int16Array>(
450            batch.columns(),
451            name,
452            index,
453            DataType::Int16,
454        )?)),
455        data_type => Err(EncodingError::InvalidColumnType(
456            name,
457            index,
458            DataType::UInt8,
459            data_type.clone(),
460        )),
461    }
462}
463
464fn u64_value(
465    values: &U64ColumnRef<'_>,
466    row: usize,
467    field: &'static str,
468) -> Result<u64, EncodingError> {
469    values.value(row).ok_or_else(|| {
470        EncodingError::ParseError(field, format!("Invalid negative value at row {row}"))
471    })
472}
473
474fn u64_col<'a>(
475    batch: &'a RecordBatch,
476    name: &'static str,
477) -> Result<U64ColumnRef<'a>, EncodingError> {
478    let index = batch.schema().index_of(name)?;
479    let column = batch.column(index);
480    U64ColumnRef::try_from_array(column.as_ref()).ok_or_else(|| {
481        EncodingError::InvalidColumnType(name, index, DataType::UInt64, column.data_type().clone())
482    })
483}
484
485fn nanos_col<'a>(
486    batch: &'a RecordBatch,
487    name: &'static str,
488) -> Result<NanosColumn<'a>, EncodingError> {
489    let index = batch.schema().index_of(name)?;
490    let column = batch.column(index);
491    let expected = timestamp_data_type();
492    if column.data_type() != &expected {
493        return Err(EncodingError::InvalidColumnType(
494            name,
495            index,
496            expected,
497            column.data_type().clone(),
498        ));
499    }
500    Ok(NanosColumn(extract_column::<TimestampNanosecondArray>(
501        batch.columns(),
502        name,
503        index,
504        expected,
505    )?))
506}
507
508fn f64_col<'a>(
509    batch: &'a RecordBatch,
510    name: &'static str,
511) -> Result<&'a Float64Array, EncodingError> {
512    let index = batch.schema().index_of(name)?;
513    extract_column::<Float64Array>(batch.columns(), name, index, DataType::Float64)
514}
515
516fn bool_col<'a>(
517    batch: &'a RecordBatch,
518    name: &'static str,
519) -> Result<&'a BooleanArray, EncodingError> {
520    let index = batch.schema().index_of(name)?;
521    extract_column::<BooleanArray>(batch.columns(), name, index, DataType::Boolean)
522}
523
524fn timestamp_array_from_nanos(values: &NanosColumn<'_>) -> ArrayRef {
525    let mut builder = TimestampNanosecondBuilder::with_capacity(values.len())
526        .with_data_type(timestamp_data_type());
527
528    for row in 0..values.len() {
529        append_timestamp(&mut builder, values, row);
530    }
531    Arc::new(builder.finish())
532}
533
534fn append_timestamp(
535    builder: &mut TimestampNanosecondBuilder,
536    values: &NanosColumn<'_>,
537    row: usize,
538) {
539    if values.is_null(row) {
540        builder.append_null();
541    } else {
542        builder.append_value(values.value(row));
543    }
544}
545
546fn timestamp_field_from(field: &Field) -> Field {
547    Field::new(field.name(), timestamp_data_type(), field.is_nullable())
548}
549
550fn fixed_precision_field_to_f64(
551    values: &FixedPrecisionColumn<'_>,
552    precision: u8,
553    is_price: bool,
554) -> Result<ArrayRef, EncodingError> {
555    let mut builder = Float64Builder::with_capacity(values.len());
556    for row in 0..values.len() {
557        if values.is_null(row) {
558            builder.append_null();
559        } else if is_price {
560            append_price(&mut builder, values, precision, "custom", row)?;
561        } else {
562            append_quantity(&mut builder, values, precision, "custom", row)?;
563        }
564    }
565
566    Ok(Arc::new(builder.finish()))
567}
568
569fn custom_fixed_precision(
570    field_name: &str,
571    metadata: &HashMap<String, String>,
572) -> Option<(u8, bool)> {
573    let normalized = field_name.to_ascii_lowercase();
574    let field_precision = metadata
575        .get(&format!("{field_name}_precision"))
576        .and_then(|value| value.parse::<u8>().ok());
577
578    match metadata
579        .get(&format!("{field_name}_kind"))
580        .map(String::as_str)
581    {
582        Some("price") => return field_precision.map(|precision| (precision, true)),
583        Some("quantity") => return field_precision.map(|precision| (precision, false)),
584        _ => {}
585    }
586    let is_quantity = matches!(
587        normalized.as_str(),
588        "size" | "quantity" | "qty" | "volume" | "bid_size" | "ask_size"
589    ) || normalized.ends_with("_size")
590        || normalized.ends_with("_quantity")
591        || normalized.ends_with("_qty")
592        || normalized.ends_with("_volume");
593
594    if is_quantity {
595        return field_precision
596            .or_else(|| {
597                metadata
598                    .get(KEY_SIZE_PRECISION)
599                    .and_then(|value| value.parse::<u8>().ok())
600            })
601            .map(|precision| (precision, false));
602    }
603
604    if let Some(precision) = field_precision {
605        return Some((precision, true));
606    }
607
608    let is_price = matches!(
609        normalized.as_str(),
610        "price"
611            | "value"
612            | "bid"
613            | "ask"
614            | "bid_price"
615            | "ask_price"
616            | "open"
617            | "high"
618            | "low"
619            | "close"
620    ) || normalized.ends_with("_price")
621        || normalized.ends_with("_value")
622        || normalized.ends_with("_bid")
623        || normalized.ends_with("_ask")
624        || normalized.ends_with("_open")
625        || normalized.ends_with("_high")
626        || normalized.ends_with("_low")
627        || normalized.ends_with("_close");
628
629    if is_price {
630        return metadata
631            .get(KEY_PRICE_PRECISION)
632            .and_then(|value| value.parse::<u8>().ok())
633            .map(|precision| (precision, true));
634    }
635
636    None
637}
638
639fn append_price(
640    builder: &mut Float64Builder,
641    values: &FixedPrecisionColumn<'_>,
642    precision: u8,
643    field: &'static str,
644    row: usize,
645) -> Result<(), EncodingError> {
646    let price = values.price(precision, field, row)?;
647    builder.append_value(price_to_f64(&price));
648    Ok(())
649}
650
651fn append_price_with_sentinel(
652    builder: &mut Float64Builder,
653    values: &FixedPrecisionColumn<'_>,
654    precision: u8,
655    field: &'static str,
656    row: usize,
657) -> Result<(), EncodingError> {
658    let price = values.price(precision, field, row)?;
659    builder.append_value(price_to_f64(&price));
660    Ok(())
661}
662
663fn append_optional_price_with_sentinel(
664    builder: &mut Float64Builder,
665    values: &FixedPrecisionColumn<'_>,
666    precision: u8,
667    field: &'static str,
668    row: usize,
669) -> Result<(), EncodingError> {
670    let price = values.price(precision, field, row)?;
671
672    if price.is_undefined() {
673        builder.append_null();
674    } else {
675        builder.append_value(price_to_f64(&price));
676    }
677    Ok(())
678}
679
680fn append_quantity(
681    builder: &mut Float64Builder,
682    values: &FixedPrecisionColumn<'_>,
683    precision: u8,
684    field: &'static str,
685    row: usize,
686) -> Result<(), EncodingError> {
687    let quantity = values.quantity(precision, field, row)?;
688    builder.append_value(quantity_to_f64(&quantity));
689    Ok(())
690}
691
692fn append_quantity_with_sentinel(
693    builder: &mut Float64Builder,
694    values: &FixedPrecisionColumn<'_>,
695    precision: u8,
696    field: &'static str,
697    row: usize,
698) -> Result<(), EncodingError> {
699    let quantity = values.quantity(precision, field, row)?;
700    builder.append_value(quantity_to_f64(&quantity));
701    Ok(())
702}
703
704fn append_optional_quantity_with_sentinel(
705    builder: &mut Float64Builder,
706    values: &FixedPrecisionColumn<'_>,
707    precision: u8,
708    field: &'static str,
709    row: usize,
710) -> Result<(), EncodingError> {
711    let quantity = values.quantity(precision, field, row)?;
712
713    if quantity.is_undefined() {
714        builder.append_null();
715    } else {
716        builder.append_value(quantity_to_f64(&quantity));
717    }
718    Ok(())
719}
720
721fn bars_schema() -> Schema {
722    Schema::new(vec![
723        utf8_field("instrument_id", false),
724        utf8_field("bar_type", false),
725        float64_field("open", false),
726        float64_field("high", false),
727        float64_field("low", false),
728        float64_field("close", false),
729        float64_field("volume", false),
730        timestamp_field("ts_event", false),
731        timestamp_field("ts_init", false),
732    ])
733}
734
735fn convert_bars(
736    metadata: &HashMap<String, String>,
737    batch: &RecordBatch,
738) -> Result<RecordBatch, EncodingError> {
739    let bar_type = bar_type(metadata)?;
740    let bar_type_string = bar_type.to_string();
741    let instrument_id_string = bar_type.instrument_id().to_string();
742    let price_precision = parse_price_precision(metadata)?;
743    let size_precision = parse_size_precision(metadata)?;
744    let open = fixed_col(batch, "open")?;
745    let high = fixed_col(batch, "high")?;
746    let low = fixed_col(batch, "low")?;
747    let close = fixed_col(batch, "close")?;
748    let volume = fixed_col(batch, "volume")?;
749    let ts_event = nanos_col(batch, "ts_event")?;
750    let ts_init = nanos_col(batch, "ts_init")?;
751
752    let len = batch.num_rows();
753    let mut open_builder = Float64Builder::with_capacity(len);
754    let mut high_builder = Float64Builder::with_capacity(len);
755    let mut low_builder = Float64Builder::with_capacity(len);
756    let mut close_builder = Float64Builder::with_capacity(len);
757    let mut volume_builder = Float64Builder::with_capacity(len);
758    let mut ts_event_builder =
759        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
760    let mut ts_init_builder =
761        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
762
763    for row in 0..len {
764        append_price(&mut open_builder, &open, price_precision, "open", row)?;
765        append_price(&mut high_builder, &high, price_precision, "high", row)?;
766        append_price(&mut low_builder, &low, price_precision, "low", row)?;
767        append_price(&mut close_builder, &close, price_precision, "close", row)?;
768        append_quantity(&mut volume_builder, &volume, size_precision, "volume", row)?;
769        append_timestamp(&mut ts_event_builder, &ts_event, row);
770        append_timestamp(&mut ts_init_builder, &ts_init, row);
771    }
772
773    RecordBatch::try_new(
774        Arc::new(bars_schema()),
775        vec![
776            constant_string_column(&instrument_id_string, len),
777            constant_string_column(&bar_type_string, len),
778            Arc::new(open_builder.finish()),
779            Arc::new(high_builder.finish()),
780            Arc::new(low_builder.finish()),
781            Arc::new(close_builder.finish()),
782            Arc::new(volume_builder.finish()),
783            Arc::new(ts_event_builder.finish()),
784            Arc::new(ts_init_builder.finish()),
785        ],
786    )
787    .map_err(EncodingError::from)
788}
789
790fn quotes_schema() -> Schema {
791    Schema::new(vec![
792        utf8_field("instrument_id", false),
793        float64_field("bid_price", false),
794        float64_field("ask_price", false),
795        float64_field("bid_size", false),
796        float64_field("ask_size", false),
797        timestamp_field("ts_event", false),
798        timestamp_field("ts_init", false),
799    ])
800}
801
802fn convert_quotes(
803    metadata: &HashMap<String, String>,
804    batch: &RecordBatch,
805) -> Result<RecordBatch, EncodingError> {
806    let instrument_id = instrument_id(metadata)?;
807    let price_precision = parse_price_precision(metadata)?;
808    let size_precision = parse_size_precision(metadata)?;
809    let bid_price = fixed_col(batch, "bid_price")?;
810    let ask_price = fixed_col(batch, "ask_price")?;
811    let bid_size = fixed_col(batch, "bid_size")?;
812    let ask_size = fixed_col(batch, "ask_size")?;
813    let ts_event = nanos_col(batch, "ts_event")?;
814    let ts_init = nanos_col(batch, "ts_init")?;
815
816    let len = batch.num_rows();
817    let mut bid_price_builder = Float64Builder::with_capacity(len);
818    let mut ask_price_builder = Float64Builder::with_capacity(len);
819    let mut bid_size_builder = Float64Builder::with_capacity(len);
820    let mut ask_size_builder = Float64Builder::with_capacity(len);
821    let mut ts_event_builder =
822        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
823    let mut ts_init_builder =
824        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
825
826    for row in 0..len {
827        append_price(
828            &mut bid_price_builder,
829            &bid_price,
830            price_precision,
831            "bid_price",
832            row,
833        )?;
834        append_price(
835            &mut ask_price_builder,
836            &ask_price,
837            price_precision,
838            "ask_price",
839            row,
840        )?;
841        append_quantity(
842            &mut bid_size_builder,
843            &bid_size,
844            size_precision,
845            "bid_size",
846            row,
847        )?;
848        append_quantity(
849            &mut ask_size_builder,
850            &ask_size,
851            size_precision,
852            "ask_size",
853            row,
854        )?;
855        append_timestamp(&mut ts_event_builder, &ts_event, row);
856        append_timestamp(&mut ts_init_builder, &ts_init, row);
857    }
858
859    RecordBatch::try_new(
860        Arc::new(quotes_schema()),
861        vec![
862            constant_string_column(instrument_id, len),
863            Arc::new(bid_price_builder.finish()),
864            Arc::new(ask_price_builder.finish()),
865            Arc::new(bid_size_builder.finish()),
866            Arc::new(ask_size_builder.finish()),
867            Arc::new(ts_event_builder.finish()),
868            Arc::new(ts_init_builder.finish()),
869        ],
870    )
871    .map_err(EncodingError::from)
872}
873
874fn trades_schema() -> Schema {
875    Schema::new(vec![
876        utf8_field("instrument_id", false),
877        float64_field("price", false),
878        float64_field("size", false),
879        utf8_field("aggressor_side", false),
880        utf8_field("trade_id", false),
881        timestamp_field("ts_event", false),
882        timestamp_field("ts_init", false),
883    ])
884}
885
886fn convert_trades(
887    metadata: &HashMap<String, String>,
888    batch: &RecordBatch,
889) -> Result<RecordBatch, EncodingError> {
890    let instrument_id = instrument_id(metadata)?;
891    let price_precision = parse_price_precision(metadata)?;
892    let size_precision = parse_size_precision(metadata)?;
893    let price = fixed_col(batch, "price")?;
894    let size = fixed_col(batch, "size")?;
895    let aggressor_side_index = batch.schema().index_of("aggressor_side")?;
896    let aggressor_side =
897        extract_column_string(batch.columns(), "aggressor_side", aggressor_side_index)?;
898    let trade_id_index = batch.schema().index_of("trade_id")?;
899    let trade_id = extract_column_string(batch.columns(), "trade_id", trade_id_index)?;
900    let ts_event = nanos_col(batch, "ts_event")?;
901    let ts_init = nanos_col(batch, "ts_init")?;
902
903    let len = batch.num_rows();
904    let mut price_builder = Float64Builder::with_capacity(len);
905    let mut size_builder = Float64Builder::with_capacity(len);
906    let mut aggressor_side_builder = StringBuilder::new();
907    let mut trade_id_builder = StringBuilder::new();
908    let mut ts_event_builder =
909        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
910    let mut ts_init_builder =
911        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
912
913    for row in 0..len {
914        let aggressor_side_value = aggressor_side.value(row);
915        let side = AggressorSide::from_str(aggressor_side_value)
916            .map_err(|e| EncodingError::ParseError(stringify!(AggressorSide), e.to_string()))?;
917        append_price(&mut price_builder, &price, price_precision, "price", row)?;
918        append_quantity(&mut size_builder, &size, size_precision, "size", row)?;
919        aggressor_side_builder.append_value(side.as_ref());
920        trade_id_builder.append_value(trade_id.value(row));
921        append_timestamp(&mut ts_event_builder, &ts_event, row);
922        append_timestamp(&mut ts_init_builder, &ts_init, row);
923    }
924
925    RecordBatch::try_new(
926        Arc::new(trades_schema()),
927        vec![
928            constant_string_column(instrument_id, len),
929            Arc::new(price_builder.finish()),
930            Arc::new(size_builder.finish()),
931            Arc::new(aggressor_side_builder.finish()),
932            Arc::new(trade_id_builder.finish()),
933            Arc::new(ts_event_builder.finish()),
934            Arc::new(ts_init_builder.finish()),
935        ],
936    )
937    .map_err(EncodingError::from)
938}
939
940fn deltas_schema() -> Schema {
941    Schema::new(vec![
942        utf8_field("instrument_id", false),
943        utf8_field("action", false),
944        utf8_field("side", false),
945        float64_field("price", false),
946        float64_field("size", false),
947        utf8_field("order_id", false),
948        Field::new("flags", DataType::UInt8, false),
949        Field::new("sequence", DataType::UInt64, false),
950        timestamp_field("ts_event", false),
951        timestamp_field("ts_init", false),
952    ])
953}
954
955fn convert_deltas(
956    metadata: &HashMap<String, String>,
957    batch: &RecordBatch,
958) -> Result<RecordBatch, EncodingError> {
959    let instrument_id = instrument_id(metadata)?;
960    let price_precision = parse_price_precision(metadata)?;
961    let size_precision = parse_size_precision(metadata)?;
962    let action_index = batch.schema().index_of("action")?;
963    let action = extract_column_string(batch.columns(), "action", action_index)?;
964    let side_index = batch.schema().index_of("side")?;
965    let side = extract_column_string(batch.columns(), "side", side_index)?;
966    let price = fixed_col(batch, "price")?;
967    let size = fixed_col(batch, "size")?;
968    let order_id = u64_col(batch, "order_id")?;
969    let flags = u8_col(batch, "flags")?;
970    let sequence = u64_col(batch, "sequence")?;
971    let ts_event = nanos_col(batch, "ts_event")?;
972    let ts_init = nanos_col(batch, "ts_init")?;
973
974    let len = batch.num_rows();
975    let mut action_builder = StringBuilder::new();
976    let mut side_builder = StringBuilder::new();
977    let mut price_builder = Float64Builder::with_capacity(len);
978    let mut size_builder = Float64Builder::with_capacity(len);
979    let mut order_id_builder = StringBuilder::new();
980    let mut flags_builder = UInt8Builder::with_capacity(len);
981    let mut sequence_builder = UInt64Builder::with_capacity(len);
982    let mut ts_event_builder =
983        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
984    let mut ts_init_builder =
985        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
986
987    for row in 0..len {
988        let action_value = action.value(row);
989        let action = BookAction::from_str(action_value)
990            .map_err(|e| EncodingError::ParseError(stringify!(BookAction), e.to_string()))?;
991        let side_value = side.value(row);
992        let side = OrderSide::from_str(side_value)
993            .map_err(|e| EncodingError::ParseError(stringify!(OrderSide), e.to_string()))?;
994
995        action_builder.append_value(action.as_ref());
996        side_builder.append_value(side.as_ref());
997        if action == BookAction::Clear {
998            price_builder.append_value(f64::NAN);
999            size_builder.append_value(f64::NAN);
1000        } else {
1001            append_price_with_sentinel(&mut price_builder, &price, price_precision, "price", row)?;
1002            append_quantity_with_sentinel(&mut size_builder, &size, size_precision, "size", row)?;
1003        }
1004        order_id_builder.append_value(u64_value(&order_id, row, "order_id")?.to_string());
1005        flags_builder.append_value(flags.value(row, "flags")?);
1006        sequence_builder.append_value(u64_value(&sequence, row, "sequence")?);
1007        append_timestamp(&mut ts_event_builder, &ts_event, row);
1008        append_timestamp(&mut ts_init_builder, &ts_init, row);
1009    }
1010
1011    RecordBatch::try_new(
1012        Arc::new(deltas_schema()),
1013        vec![
1014            constant_string_column(instrument_id, len),
1015            Arc::new(action_builder.finish()),
1016            Arc::new(side_builder.finish()),
1017            Arc::new(price_builder.finish()),
1018            Arc::new(size_builder.finish()),
1019            Arc::new(order_id_builder.finish()),
1020            Arc::new(flags_builder.finish()),
1021            Arc::new(sequence_builder.finish()),
1022            Arc::new(ts_event_builder.finish()),
1023            Arc::new(ts_init_builder.finish()),
1024        ],
1025    )
1026    .map_err(EncodingError::from)
1027}
1028
1029fn convert_depths(
1030    metadata: &HashMap<String, String>,
1031    batch: &RecordBatch,
1032) -> Result<RecordBatch, EncodingError> {
1033    let instrument_id = instrument_id(metadata)?;
1034    let price_precision = parse_price_precision(metadata)?;
1035    let size_precision = parse_size_precision(metadata)?;
1036    let bids = depth_struct_values(batch, "bids")?;
1037    let asks = depth_struct_values(batch, "asks")?;
1038    let flags = u8_col(batch, "flags")?;
1039    let sequence = u64_col(batch, "sequence")?;
1040    let ts_event = nanos_col(batch, "ts_event")?;
1041    let ts_init = nanos_col(batch, "ts_init")?;
1042    let len = batch.num_rows();
1043    let mut bids_display = DepthSideBuilder::new();
1044    let mut asks_display = DepthSideBuilder::new();
1045    let mut flags_builder = UInt8Builder::with_capacity(len);
1046    let mut sequence_builder = UInt64Builder::with_capacity(len);
1047    let mut ts_event_builder =
1048        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1049    let mut ts_init_builder =
1050        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1051    let bid_prices = FixedPrecisionColumn(&bids.prices);
1052    let ask_prices = FixedPrecisionColumn(&asks.prices);
1053    let bid_sizes = FixedPrecisionColumn(&bids.sizes);
1054    let ask_sizes = FixedPrecisionColumn(&asks.sizes);
1055
1056    for row in 0..len {
1057        let (bid_start, bid_end) = depth_struct_range(&bids.list, "bids", row)?;
1058        let (ask_start, ask_end) = depth_struct_range(&asks.list, "asks", row)?;
1059
1060        for value_index in bid_start..bid_end {
1061            append_optional_price_with_sentinel(
1062                &mut bids_display.prices,
1063                &bid_prices,
1064                price_precision,
1065                "bids.price",
1066                value_index,
1067            )?;
1068            append_optional_quantity_with_sentinel(
1069                &mut bids_display.sizes,
1070                &bid_sizes,
1071                size_precision,
1072                "bids.size",
1073                value_index,
1074            )?;
1075
1076            if bids.counts.is_null(value_index) || bids.order_ids.is_null(value_index) {
1077                return Err(EncodingError::ParseError(
1078                    "bids",
1079                    "count and order_id must not be null".to_string(),
1080                ));
1081            }
1082            bids_display
1083                .counts
1084                .append_value(bids.counts.value(value_index));
1085            bids_display
1086                .order_ids
1087                .append_value(bids.order_ids.value(value_index));
1088        }
1089
1090        for value_index in ask_start..ask_end {
1091            append_optional_price_with_sentinel(
1092                &mut asks_display.prices,
1093                &ask_prices,
1094                price_precision,
1095                "asks.price",
1096                value_index,
1097            )?;
1098            append_optional_quantity_with_sentinel(
1099                &mut asks_display.sizes,
1100                &ask_sizes,
1101                size_precision,
1102                "asks.size",
1103                value_index,
1104            )?;
1105
1106            if asks.counts.is_null(value_index) || asks.order_ids.is_null(value_index) {
1107                return Err(EncodingError::ParseError(
1108                    "asks",
1109                    "count and order_id must not be null".to_string(),
1110                ));
1111            }
1112            asks_display
1113                .counts
1114                .append_value(asks.counts.value(value_index));
1115            asks_display
1116                .order_ids
1117                .append_value(asks.order_ids.value(value_index));
1118        }
1119        bids_display.finish_row()?;
1120        asks_display.finish_row()?;
1121        flags_builder.append_value(flags.value(row, "flags")?);
1122        sequence_builder.append_value(u64_value(&sequence, row, "sequence")?);
1123        append_timestamp(&mut ts_event_builder, &ts_event, row);
1124        append_timestamp(&mut ts_init_builder, &ts_init, row);
1125    }
1126
1127    finish_depth_display(
1128        instrument_id,
1129        len,
1130        bids_display,
1131        asks_display,
1132        flags_builder,
1133        sequence_builder,
1134        ts_event_builder,
1135        ts_init_builder,
1136    )
1137}
1138
1139#[expect(
1140    clippy::too_many_arguments,
1141    reason = "the two nested sides and four scalar builders form one batch"
1142)]
1143fn finish_depth_display(
1144    instrument_id: &str,
1145    len: usize,
1146    bids: DepthSideBuilder,
1147    asks: DepthSideBuilder,
1148    mut flags: UInt8Builder,
1149    mut sequence: UInt64Builder,
1150    mut ts_event: TimestampNanosecondBuilder,
1151    mut ts_init: TimestampNanosecondBuilder,
1152) -> Result<RecordBatch, EncodingError> {
1153    RecordBatch::try_new(
1154        Arc::new(depth_schema()),
1155        vec![
1156            constant_string_column(instrument_id, len),
1157            Arc::new(bids.finish()?),
1158            Arc::new(asks.finish()?),
1159            Arc::new(flags.finish()),
1160            Arc::new(sequence.finish()),
1161            Arc::new(ts_event.finish()),
1162            Arc::new(ts_init.finish()),
1163        ],
1164    )
1165    .map_err(EncodingError::from)
1166}
1167
1168struct DepthStructColumns {
1169    list: ListArray,
1170    prices: Decimal128Array,
1171    sizes: Decimal128Array,
1172    counts: UInt32Array,
1173    order_ids: UInt64Array,
1174}
1175
1176fn depth_struct_values(
1177    batch: &RecordBatch,
1178    name: &'static str,
1179) -> Result<DepthStructColumns, EncodingError> {
1180    let list = batch
1181        .column_by_name(name)
1182        .and_then(|column| column.as_any().downcast_ref::<ListArray>())
1183        .cloned()
1184        .ok_or_else(|| EncodingError::ParseError(name, "expected List<Struct>".to_string()))?;
1185    let levels = list
1186        .values()
1187        .as_any()
1188        .downcast_ref::<StructArray>()
1189        .ok_or_else(|| EncodingError::ParseError(name, "expected List<Struct>".to_string()))?;
1190    let child = |field: &'static str| {
1191        levels.column_by_name(field).cloned().ok_or_else(|| {
1192            EncodingError::ParseError(name, format!("missing struct field '{field}'"))
1193        })
1194    };
1195
1196    for field in ["price", "size"] {
1197        if child(field)?.data_type() != &fixed_decimal_data_type() {
1198            return Err(EncodingError::ParseError(
1199                name,
1200                format!("field '{field}' must use Decimal128(38, 16)"),
1201            ));
1202        }
1203    }
1204    let prices = child("price")?
1205        .as_any()
1206        .downcast_ref::<Decimal128Array>()
1207        .cloned()
1208        .ok_or_else(|| {
1209            EncodingError::ParseError(name, "field 'price' must be Decimal128".to_string())
1210        })?;
1211    let sizes = child("size")?
1212        .as_any()
1213        .downcast_ref::<Decimal128Array>()
1214        .cloned()
1215        .ok_or_else(|| {
1216            EncodingError::ParseError(name, "field 'size' must be Decimal128".to_string())
1217        })?;
1218    let counts = child("count")?
1219        .as_any()
1220        .downcast_ref::<UInt32Array>()
1221        .cloned()
1222        .ok_or_else(|| {
1223            EncodingError::ParseError(name, "field 'count' must be UInt32".to_string())
1224        })?;
1225    let order_ids = child("order_id")?
1226        .as_any()
1227        .downcast_ref::<UInt64Array>()
1228        .cloned()
1229        .ok_or_else(|| {
1230            EncodingError::ParseError(name, "field 'order_id' must be UInt64".to_string())
1231        })?;
1232    Ok(DepthStructColumns {
1233        list,
1234        prices,
1235        sizes,
1236        counts,
1237        order_ids,
1238    })
1239}
1240
1241fn depth_struct_range(
1242    list: &ListArray,
1243    name: &'static str,
1244    row: usize,
1245) -> Result<(usize, usize), EncodingError> {
1246    if list.is_null(row) {
1247        return Err(EncodingError::ParseError(
1248            name,
1249            format!("side is null at row {row}"),
1250        ));
1251    }
1252    let offsets = list.value_offsets();
1253    let start = usize::try_from(offsets[row])
1254        .map_err(|e| EncodingError::ParseError(name, e.to_string()))?;
1255    let end = usize::try_from(offsets[row + 1])
1256        .map_err(|e| EncodingError::ParseError(name, e.to_string()))?;
1257    Ok((start, end))
1258}
1259
1260fn price_update_schema() -> Schema {
1261    Schema::new(vec![
1262        utf8_field("instrument_id", false),
1263        float64_field("value", false),
1264        timestamp_field("ts_event", false),
1265        timestamp_field("ts_init", false),
1266    ])
1267}
1268
1269fn convert_price_updates(
1270    metadata: &HashMap<String, String>,
1271    batch: &RecordBatch,
1272) -> Result<RecordBatch, EncodingError> {
1273    let instrument_id = instrument_id(metadata)?;
1274    let price_precision = parse_price_precision(metadata)?;
1275    let value = fixed_col(batch, "value")?;
1276    let ts_event = nanos_col(batch, "ts_event")?;
1277    let ts_init = nanos_col(batch, "ts_init")?;
1278    let len = batch.num_rows();
1279    let mut value_builder = Float64Builder::with_capacity(len);
1280    let mut ts_event_builder =
1281        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1282    let mut ts_init_builder =
1283        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1284
1285    for row in 0..len {
1286        append_price(&mut value_builder, &value, price_precision, "value", row)?;
1287        append_timestamp(&mut ts_event_builder, &ts_event, row);
1288        append_timestamp(&mut ts_init_builder, &ts_init, row);
1289    }
1290
1291    RecordBatch::try_new(
1292        Arc::new(price_update_schema()),
1293        vec![
1294            constant_string_column(instrument_id, len),
1295            Arc::new(value_builder.finish()),
1296            Arc::new(ts_event_builder.finish()),
1297            Arc::new(ts_init_builder.finish()),
1298        ],
1299    )
1300    .map_err(EncodingError::from)
1301}
1302
1303fn funding_rate_schema() -> Schema {
1304    Schema::new(vec![
1305        utf8_field("instrument_id", false),
1306        float64_field("rate", false),
1307        Field::new("interval", DataType::UInt64, true),
1308        timestamp_field("next_funding_ns", true),
1309        timestamp_field("ts_event", false),
1310        timestamp_field("ts_init", false),
1311    ])
1312}
1313
1314fn convert_funding_rates(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1315    let instrument_id_index = batch.schema().index_of("instrument_id")?;
1316    let instrument_id =
1317        extract_column_string(batch.columns(), "instrument_id", instrument_id_index)?;
1318    let rate_index = batch.schema().index_of("rate")?;
1319    let rate = extract_column_string(batch.columns(), "rate", rate_index)?;
1320    let interval = u64_col(batch, "interval")?;
1321    let next_funding_ns = nanos_col(batch, "next_funding_ns")?;
1322    let ts_event = nanos_col(batch, "ts_event")?;
1323    let ts_init = nanos_col(batch, "ts_init")?;
1324    let len = batch.num_rows();
1325
1326    let mut instrument_id_builder = StringBuilder::new();
1327    let mut rate_builder = Float64Builder::with_capacity(len);
1328    let mut interval_builder = UInt64Builder::with_capacity(len);
1329    let mut next_funding_ns_builder =
1330        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1331    let mut ts_event_builder =
1332        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1333    let mut ts_init_builder =
1334        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1335
1336    for row in 0..len {
1337        instrument_id_builder.append_value(instrument_id.value(row));
1338        let value = Decimal::from_str(rate.value(row))
1339            .map_err(|e| EncodingError::ParseError("rate", e.to_string()))?;
1340        rate_builder.append_value(value.to_f64().unwrap_or(f64::NAN));
1341        append_optional_u64(&mut interval_builder, &interval, "interval", row)?;
1342        append_timestamp(&mut next_funding_ns_builder, &next_funding_ns, row);
1343        append_timestamp(&mut ts_event_builder, &ts_event, row);
1344        append_timestamp(&mut ts_init_builder, &ts_init, row);
1345    }
1346
1347    RecordBatch::try_new(
1348        Arc::new(funding_rate_schema()),
1349        vec![
1350            Arc::new(instrument_id_builder.finish()),
1351            Arc::new(rate_builder.finish()),
1352            Arc::new(interval_builder.finish()),
1353            Arc::new(next_funding_ns_builder.finish()),
1354            Arc::new(ts_event_builder.finish()),
1355            Arc::new(ts_init_builder.finish()),
1356        ],
1357    )
1358    .map_err(EncodingError::from)
1359}
1360
1361fn instrument_closes_schema() -> Schema {
1362    Schema::new(vec![
1363        utf8_field("instrument_id", false),
1364        float64_field("close_price", false),
1365        utf8_field("close_type", false),
1366        timestamp_field("ts_event", false),
1367        timestamp_field("ts_init", false),
1368    ])
1369}
1370
1371fn convert_instrument_closes(
1372    metadata: &HashMap<String, String>,
1373    batch: &RecordBatch,
1374) -> Result<RecordBatch, EncodingError> {
1375    let instrument_id = instrument_id(metadata)?;
1376    let price_precision = parse_price_precision(metadata)?;
1377    let close_price = fixed_col(batch, "close_price")?;
1378    let close_type_index = batch.schema().index_of("close_type")?;
1379    let close_type = extract_column_string(batch.columns(), "close_type", close_type_index)?;
1380    let ts_event = nanos_col(batch, "ts_event")?;
1381    let ts_init = nanos_col(batch, "ts_init")?;
1382    let len = batch.num_rows();
1383    let mut close_price_builder = Float64Builder::with_capacity(len);
1384    let mut close_type_builder = StringBuilder::new();
1385    let mut ts_event_builder =
1386        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1387    let mut ts_init_builder =
1388        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1389
1390    for row in 0..len {
1391        let close_type_value = close_type.value(row);
1392        let close_type = InstrumentCloseType::from_str(close_type_value).map_err(|e| {
1393            EncodingError::ParseError(stringify!(InstrumentCloseType), e.to_string())
1394        })?;
1395        append_price(
1396            &mut close_price_builder,
1397            &close_price,
1398            price_precision,
1399            "close_price",
1400            row,
1401        )?;
1402        close_type_builder.append_value(close_type.as_ref());
1403        append_timestamp(&mut ts_event_builder, &ts_event, row);
1404        append_timestamp(&mut ts_init_builder, &ts_init, row);
1405    }
1406
1407    RecordBatch::try_new(
1408        Arc::new(instrument_closes_schema()),
1409        vec![
1410            constant_string_column(instrument_id, len),
1411            Arc::new(close_price_builder.finish()),
1412            Arc::new(close_type_builder.finish()),
1413            Arc::new(ts_event_builder.finish()),
1414            Arc::new(ts_init_builder.finish()),
1415        ],
1416    )
1417    .map_err(EncodingError::from)
1418}
1419
1420fn option_greeks_schema() -> Schema {
1421    Schema::new(vec![
1422        utf8_field("instrument_id", false),
1423        float64_field("delta", false),
1424        float64_field("gamma", false),
1425        float64_field("vega", false),
1426        float64_field("theta", false),
1427        float64_field("rho", false),
1428        float64_field("mark_iv", true),
1429        float64_field("bid_iv", true),
1430        float64_field("ask_iv", true),
1431        float64_field("underlying_price", true),
1432        float64_field("open_interest", true),
1433        timestamp_field("ts_event", false),
1434        timestamp_field("ts_init", false),
1435        utf8_field("convention", false),
1436    ])
1437}
1438
1439fn append_optional_f64(builder: &mut Float64Builder, values: &Float64Array, row: usize) {
1440    if values.is_null(row) {
1441        builder.append_null();
1442    } else {
1443        builder.append_value(values.value(row));
1444    }
1445}
1446
1447fn convert_option_greeks(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1448    let instrument_id_index = batch.schema().index_of("instrument_id")?;
1449    let instrument_id =
1450        extract_column_string(batch.columns(), "instrument_id", instrument_id_index)?;
1451    let delta = f64_col(batch, "delta")?;
1452    let gamma = f64_col(batch, "gamma")?;
1453    let vega = f64_col(batch, "vega")?;
1454    let theta = f64_col(batch, "theta")?;
1455    let rho = f64_col(batch, "rho")?;
1456    let mark_iv = f64_col(batch, "mark_iv")?;
1457    let bid_iv = f64_col(batch, "bid_iv")?;
1458    let ask_iv = f64_col(batch, "ask_iv")?;
1459    let underlying_price = f64_col(batch, "underlying_price")?;
1460    let open_interest = f64_col(batch, "open_interest")?;
1461    let ts_event = nanos_col(batch, "ts_event")?;
1462    let ts_init = nanos_col(batch, "ts_init")?;
1463    let convention_index = batch.schema().index_of("convention")?;
1464    let convention = extract_column_string(batch.columns(), "convention", convention_index)?;
1465    let len = batch.num_rows();
1466
1467    let mut instrument_id_builder = StringBuilder::new();
1468    let mut delta_builder = Float64Builder::with_capacity(len);
1469    let mut gamma_builder = Float64Builder::with_capacity(len);
1470    let mut vega_builder = Float64Builder::with_capacity(len);
1471    let mut theta_builder = Float64Builder::with_capacity(len);
1472    let mut rho_builder = Float64Builder::with_capacity(len);
1473    let mut mark_iv_builder = Float64Builder::with_capacity(len);
1474    let mut bid_iv_builder = Float64Builder::with_capacity(len);
1475    let mut ask_iv_builder = Float64Builder::with_capacity(len);
1476    let mut underlying_price_builder = Float64Builder::with_capacity(len);
1477    let mut open_interest_builder = Float64Builder::with_capacity(len);
1478    let mut ts_event_builder =
1479        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1480    let mut ts_init_builder =
1481        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1482    let mut convention_builder = StringBuilder::new();
1483
1484    for row in 0..len {
1485        instrument_id_builder.append_value(instrument_id.value(row));
1486        delta_builder.append_value(delta.value(row));
1487        gamma_builder.append_value(gamma.value(row));
1488        vega_builder.append_value(vega.value(row));
1489        theta_builder.append_value(theta.value(row));
1490        rho_builder.append_value(rho.value(row));
1491        append_optional_f64(&mut mark_iv_builder, mark_iv, row);
1492        append_optional_f64(&mut bid_iv_builder, bid_iv, row);
1493        append_optional_f64(&mut ask_iv_builder, ask_iv, row);
1494        append_optional_f64(&mut underlying_price_builder, underlying_price, row);
1495        append_optional_f64(&mut open_interest_builder, open_interest, row);
1496        append_timestamp(&mut ts_event_builder, &ts_event, row);
1497        append_timestamp(&mut ts_init_builder, &ts_init, row);
1498        convention_builder.append_value(convention.value(row));
1499    }
1500
1501    RecordBatch::try_new(
1502        Arc::new(option_greeks_schema()),
1503        vec![
1504            Arc::new(instrument_id_builder.finish()),
1505            Arc::new(delta_builder.finish()),
1506            Arc::new(gamma_builder.finish()),
1507            Arc::new(vega_builder.finish()),
1508            Arc::new(theta_builder.finish()),
1509            Arc::new(rho_builder.finish()),
1510            Arc::new(mark_iv_builder.finish()),
1511            Arc::new(bid_iv_builder.finish()),
1512            Arc::new(ask_iv_builder.finish()),
1513            Arc::new(underlying_price_builder.finish()),
1514            Arc::new(open_interest_builder.finish()),
1515            Arc::new(ts_event_builder.finish()),
1516            Arc::new(ts_init_builder.finish()),
1517            Arc::new(convention_builder.finish()),
1518        ],
1519    )
1520    .map_err(EncodingError::from)
1521}
1522
1523fn instrument_status_schema() -> Schema {
1524    Schema::new(vec![
1525        utf8_field("instrument_id", false),
1526        utf8_field("action", false),
1527        timestamp_field("ts_event", false),
1528        timestamp_field("ts_init", false),
1529        utf8_field("reason", true),
1530        utf8_field("trading_event", true),
1531        Field::new("is_trading", DataType::Boolean, true),
1532        Field::new("is_quoting", DataType::Boolean, true),
1533        Field::new("is_short_sell_restricted", DataType::Boolean, true),
1534    ])
1535}
1536
1537fn append_optional_string(
1538    builder: &mut StringBuilder,
1539    values: &super::StringColumnRef<'_>,
1540    row: usize,
1541    array: &dyn Array,
1542) {
1543    if array.is_null(row) {
1544        builder.append_null();
1545    } else {
1546        builder.append_value(values.value(row));
1547    }
1548}
1549
1550fn append_optional_bool(
1551    builder: &mut arrow::array::BooleanBuilder,
1552    values: &BooleanArray,
1553    row: usize,
1554) {
1555    if values.is_null(row) {
1556        builder.append_null();
1557    } else {
1558        builder.append_value(values.value(row));
1559    }
1560}
1561
1562fn append_optional_u64(
1563    builder: &mut UInt64Builder,
1564    values: &U64ColumnRef<'_>,
1565    field: &'static str,
1566    row: usize,
1567) -> Result<(), EncodingError> {
1568    if values.is_null(row) {
1569        builder.append_null();
1570    } else {
1571        builder.append_value(u64_value(values, row, field)?);
1572    }
1573    Ok(())
1574}
1575
1576fn convert_instrument_status(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1577    let instrument_id_index = batch.schema().index_of("instrument_id")?;
1578    let instrument_id =
1579        extract_column_string(batch.columns(), "instrument_id", instrument_id_index)?;
1580    let action_index = batch.schema().index_of("action")?;
1581    let action = extract_column_string(batch.columns(), "action", action_index)?;
1582    let ts_event = nanos_col(batch, "ts_event")?;
1583    let ts_init = nanos_col(batch, "ts_init")?;
1584    let reason_index = batch.schema().index_of("reason")?;
1585    let reason = extract_column_string(batch.columns(), "reason", reason_index)?;
1586    let trading_event_index = batch.schema().index_of("trading_event")?;
1587    let trading_event =
1588        extract_column_string(batch.columns(), "trading_event", trading_event_index)?;
1589    let is_trading = bool_col(batch, "is_trading")?;
1590    let is_quoting = bool_col(batch, "is_quoting")?;
1591    let is_short_sell_restricted = bool_col(batch, "is_short_sell_restricted")?;
1592    let len = batch.num_rows();
1593
1594    let mut instrument_id_builder = StringBuilder::new();
1595    let mut action_builder = StringBuilder::new();
1596    let mut ts_event_builder =
1597        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1598    let mut ts_init_builder =
1599        TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1600    let mut reason_builder = StringBuilder::new();
1601    let mut trading_event_builder = StringBuilder::new();
1602    let mut is_trading_builder = arrow::array::BooleanBuilder::with_capacity(len);
1603    let mut is_quoting_builder = arrow::array::BooleanBuilder::with_capacity(len);
1604    let mut is_short_sell_restricted_builder = arrow::array::BooleanBuilder::with_capacity(len);
1605
1606    for row in 0..len {
1607        instrument_id_builder.append_value(instrument_id.value(row));
1608        action_builder.append_value(action.value(row));
1609        append_timestamp(&mut ts_event_builder, &ts_event, row);
1610        append_timestamp(&mut ts_init_builder, &ts_init, row);
1611        append_optional_string(
1612            &mut reason_builder,
1613            &reason,
1614            row,
1615            batch.column(reason_index).as_ref(),
1616        );
1617        append_optional_string(
1618            &mut trading_event_builder,
1619            &trading_event,
1620            row,
1621            batch.column(trading_event_index).as_ref(),
1622        );
1623        append_optional_bool(&mut is_trading_builder, is_trading, row);
1624        append_optional_bool(&mut is_quoting_builder, is_quoting, row);
1625        append_optional_bool(
1626            &mut is_short_sell_restricted_builder,
1627            is_short_sell_restricted,
1628            row,
1629        );
1630    }
1631
1632    RecordBatch::try_new(
1633        Arc::new(instrument_status_schema()),
1634        vec![
1635            Arc::new(instrument_id_builder.finish()),
1636            Arc::new(action_builder.finish()),
1637            Arc::new(ts_event_builder.finish()),
1638            Arc::new(ts_init_builder.finish()),
1639            Arc::new(reason_builder.finish()),
1640            Arc::new(trading_event_builder.finish()),
1641            Arc::new(is_trading_builder.finish()),
1642            Arc::new(is_quoting_builder.finish()),
1643            Arc::new(is_short_sell_restricted_builder.finish()),
1644        ],
1645    )
1646    .map_err(EncodingError::from)
1647}
1648
1649/// Converts custom data to display types.
1650///
1651/// For a registered custom type, the output uses its canonical registered schema metadata.
1652/// User-provided `DataType` metadata keys are therefore not retained in the display schema.
1653fn convert_custom(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1654    let batch_schema = batch.schema();
1655    let metadata = batch_schema.metadata();
1656    let mut fields = Vec::with_capacity(batch.num_columns());
1657    let mut columns = Vec::with_capacity(batch.num_columns());
1658
1659    for (field, column) in batch_schema.fields().iter().zip(batch.columns()) {
1660        if matches!(field.name().as_str(), "ts_event" | "ts_init") {
1661            let name = if field.name() == "ts_event" {
1662                "ts_event"
1663            } else {
1664                "ts_init"
1665            };
1666            let values = nanos_col(batch, name)?;
1667            fields.push(Arc::new(timestamp_field_from(field)));
1668            columns.push(timestamp_array_from_nanos(&values));
1669        } else if let Some((precision, is_price)) = custom_fixed_precision(field.name(), metadata)
1670            && matches!(
1671                field.data_type(),
1672                DataType::Decimal128(_, _)
1673                    | DataType::FixedSizeBinary(_)
1674                    | DataType::Binary
1675                    | DataType::BinaryView
1676            )
1677        {
1678            if field.data_type() != &fixed_decimal_data_type() {
1679                return Err(EncodingError::ParseError(
1680                    "custom",
1681                    format!(
1682                        "{} must use Decimal128(38, 16); migrate legacy data before display",
1683                        field.name()
1684                    ),
1685                ));
1686            }
1687            let values = FixedPrecisionColumn(
1688                column
1689                    .as_any()
1690                    .downcast_ref::<Decimal128Array>()
1691                    .expect("decimal field type checked above"),
1692            );
1693            fields.push(Arc::new(Field::new(
1694                field.name(),
1695                DataType::Float64,
1696                field.is_nullable(),
1697            )));
1698            columns.push(fixed_precision_field_to_f64(&values, precision, is_price)?);
1699        } else if field.data_type() == &DataType::Utf8View {
1700            fields.push(Arc::new(Field::new(
1701                field.name(),
1702                DataType::Utf8,
1703                field.is_nullable(),
1704            )));
1705            columns.push(cast(column.as_ref(), &DataType::Utf8)?);
1706        } else {
1707            fields.push(field.clone());
1708            columns.push(column.clone());
1709        }
1710    }
1711
1712    let metadata = if let Some(type_name) = metadata.get("type_name")
1713        && get_arrow_schema(type_name).is_some()
1714    {
1715        let canonical_metadata = HashMap::from([("type_name".to_string(), type_name.clone())]);
1716        let canonical_schema = super::schema_with_identifier_column(
1717            &CustomDataDecoder::get_schema(Some(canonical_metadata)),
1718        );
1719
1720        for field in &mut fields {
1721            if let Ok(canonical_field) = canonical_schema.field_with_name(field.name()) {
1722                *field = Arc::new(
1723                    field
1724                        .as_ref()
1725                        .clone()
1726                        .with_nullable(canonical_field.is_nullable()),
1727                );
1728            }
1729        }
1730        canonical_schema.metadata().clone()
1731    } else {
1732        batch_schema.metadata().clone()
1733    };
1734    let schema = Schema::new_with_metadata(fields, metadata);
1735    RecordBatch::try_new(Arc::new(schema), columns).map_err(EncodingError::from)
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740    use std::{collections::HashMap, str::FromStr};
1741
1742    use arrow::{
1743        array::{
1744            Array, FixedSizeBinaryArray, FixedSizeListArray, Float64Array, StringArray,
1745            StringViewArray, UInt64Array,
1746        },
1747        datatypes::{Field, TimeUnit},
1748    };
1749    use nautilus_core::UnixNanos;
1750    #[cfg(feature = "arrow-display")]
1751    use nautilus_model::data::stubs::{
1752        stub_bar, stub_delta, stub_instrument_close, stub_trade_ethusdt_buy,
1753    };
1754    use nautilus_model::{
1755        data::{
1756            FundingRateUpdate, OrderBookDepth, QuoteTick, ensure_arrow_registered,
1757            stubs::stub_depth10,
1758        },
1759        identifiers::InstrumentId,
1760        types::{Price, Quantity},
1761    };
1762    use rstest::rstest;
1763    use rust_decimal::Decimal;
1764
1765    use super::*;
1766    use crate::arrow::{
1767        EncodeToRecordBatch, KEY_INSTRUMENT_ID, KEY_PRICE_PRECISION, KEY_SIZE_PRECISION,
1768    };
1769    #[cfg(feature = "arrow-display")]
1770    use crate::arrow::{
1771        display::{
1772            bar::encode_bars, close::encode_instrument_closes, delta::encode_deltas,
1773            trade::encode_trades,
1774        },
1775        record_batch_without_identifier_column,
1776    };
1777
1778    #[rstest]
1779    fn test_catalog_record_batch_to_display_converts_quote_prices_and_metadata() {
1780        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1781        let metadata = HashMap::from([
1782            (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
1783            (KEY_PRICE_PRECISION.to_string(), "5".to_string()),
1784            (KEY_SIZE_PRECISION.to_string(), "0".to_string()),
1785        ]);
1786        let quote = QuoteTick {
1787            instrument_id,
1788            bid_price: Price::from("1.00001"),
1789            ask_price: Price::from("1.00003"),
1790            bid_size: Quantity::from(1_000),
1791            ask_size: Quantity::from(2_000),
1792            ts_event: 10.into(),
1793            ts_init: 11.into(),
1794        };
1795        let raw_batch = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
1796
1797        let display_batch =
1798            catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &raw_batch)
1799                .unwrap();
1800
1801        let mut expected_fields = quotes_schema().fields().to_vec();
1802        expected_fields.push(Arc::new(utf8_field(KEY_IDENTIFIER, true)));
1803        assert_eq!(
1804            display_batch.schema(),
1805            Arc::new(Schema::new(expected_fields))
1806        );
1807        let instrument_ids = display_batch
1808            .column(0)
1809            .as_any()
1810            .downcast_ref::<StringArray>()
1811            .unwrap();
1812        let bid_prices = display_batch
1813            .column(1)
1814            .as_any()
1815            .downcast_ref::<Float64Array>()
1816            .unwrap();
1817        let ask_sizes = display_batch
1818            .column(4)
1819            .as_any()
1820            .downcast_ref::<Float64Array>()
1821            .unwrap();
1822        let ts_init = display_batch
1823            .column(6)
1824            .as_any()
1825            .downcast_ref::<arrow::array::TimestampNanosecondArray>()
1826            .unwrap();
1827        let identifiers = display_batch
1828            .column(7)
1829            .as_any()
1830            .downcast_ref::<StringArray>()
1831            .unwrap();
1832
1833        assert_eq!(instrument_ids.value(0), "AUD/USD.SIM");
1834        assert_eq!(bid_prices.value(0), 1.00001);
1835        assert_eq!(ask_sizes.value(0), 2_000.0);
1836        assert_eq!(ts_init.value(0), 11);
1837        assert_eq!(identifiers.value(0), "AUD/USD.SIM");
1838    }
1839
1840    #[rstest]
1841    #[case(1)]
1842    #[case(u64::MAX)]
1843    fn test_catalog_record_batch_to_display_rejects_legacy_timestamps(#[case] timestamp: u64) {
1844        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1845        let metadata = HashMap::from([
1846            (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
1847            (KEY_PRICE_PRECISION.to_string(), "5".to_string()),
1848            (KEY_SIZE_PRECISION.to_string(), "0".to_string()),
1849        ]);
1850        let quote = QuoteTick {
1851            instrument_id,
1852            bid_price: Price::from("1.00001"),
1853            ask_price: Price::from("1.00003"),
1854            bid_size: Quantity::from(1_000),
1855            ask_size: Quantity::from(2_000),
1856            ts_event: 10.into(),
1857            ts_init: 11.into(),
1858        };
1859        let raw_batch = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
1860        let mut fields = raw_batch.schema().fields().to_vec();
1861        let ts_init_index = raw_batch.schema().index_of("ts_init").unwrap();
1862        fields[ts_init_index] = Arc::new(Field::new("ts_init", DataType::UInt64, false));
1863        let mut columns = raw_batch.columns().to_vec();
1864        columns[ts_init_index] = Arc::new(UInt64Array::from(vec![timestamp]));
1865        let raw_batch = RecordBatch::try_new(
1866            Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
1867            columns,
1868        )
1869        .unwrap();
1870
1871        let error =
1872            catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &raw_batch)
1873                .unwrap_err();
1874
1875        assert!(matches!(
1876            error,
1877            EncodingError::InvalidColumnType(
1878                "ts_init",
1879                _,
1880                DataType::Timestamp(TimeUnit::Nanosecond, Some(timezone)),
1881                DataType::UInt64
1882            ) if timezone.as_ref() == "UTC"
1883        ));
1884    }
1885
1886    #[rstest]
1887    #[case(38, 15)]
1888    #[case(37, 16)]
1889    fn test_catalog_display_rejects_noncanonical_decimal_schema(
1890        #[case] precision: u8,
1891        #[case] scale: i8,
1892    ) {
1893        let quote = QuoteTick::new(
1894            InstrumentId::from("AUD/USD.SIM"),
1895            Price::from("1.25"),
1896            Price::from("1.50"),
1897            Quantity::from(10),
1898            Quantity::from(20),
1899            10.into(),
1900            11.into(),
1901        );
1902        let metadata = quote.metadata();
1903        let raw = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
1904        let index = raw.schema().index_of("bid_price").unwrap();
1905        let mut fields = raw.schema().fields().to_vec();
1906        let mut columns = raw.columns().to_vec();
1907        let values = columns[index]
1908            .as_any()
1909            .downcast_ref::<Decimal128Array>()
1910            .unwrap()
1911            .clone()
1912            .with_precision_and_scale(precision, scale)
1913            .unwrap();
1914        fields[index] = Arc::new(
1915            fields[index]
1916                .as_ref()
1917                .clone()
1918                .with_data_type(values.data_type().clone()),
1919        );
1920        columns[index] = Arc::new(values);
1921        let batch = RecordBatch::try_new(
1922            Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
1923            columns,
1924        )
1925        .unwrap();
1926        let error =
1927            catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &batch)
1928                .unwrap_err();
1929
1930        assert!(
1931            matches!(error, EncodingError::InvalidColumnType("bid_price", i, DataType::Decimal128(38, 16), actual) if i == index && actual == DataType::Decimal128(precision, scale))
1932        );
1933    }
1934
1935    #[rstest]
1936    #[case(0)]
1937    #[case(5)]
1938    #[case(10)]
1939    #[case(25)]
1940    fn test_depth_display_preserves_all_levels(#[case] levels: usize) {
1941        let mut depth = stub_depth10();
1942        depth.bids.resize(levels, depth.bids[0]);
1943        depth.bid_counts.resize(levels, 0);
1944        depth.asks.clear();
1945        depth.ask_counts.clear();
1946        for (i, (order, count)) in depth.bids.iter_mut().zip(&mut depth.bid_counts).enumerate() {
1947            order.price = format!("{}.25", 100 + i).parse().unwrap();
1948            order.size = format!("{}.5", 200 + i).parse().unwrap();
1949            order.order_id = u64::MAX - u64::try_from(i).unwrap();
1950            *count = 300 + u32::try_from(i).unwrap();
1951        }
1952        let metadata = depth.metadata();
1953        let raw = OrderBookDepth::encode_batch(&metadata, &[depth]).unwrap();
1954        let display =
1955            catalog_record_batch_to_display(&NautilusDataType::OrderBookDepth, &metadata, &raw)
1956                .unwrap();
1957        let list = display
1958            .column_by_name("bids")
1959            .unwrap()
1960            .as_any()
1961            .downcast_ref::<ListArray>()
1962            .unwrap();
1963        let values = list.value(0);
1964        let structs = values.as_any().downcast_ref::<StructArray>().unwrap();
1965        let prices = structs
1966            .column_by_name("price")
1967            .unwrap()
1968            .as_any()
1969            .downcast_ref::<Float64Array>()
1970            .unwrap();
1971        let sizes = structs
1972            .column_by_name("size")
1973            .unwrap()
1974            .as_any()
1975            .downcast_ref::<Float64Array>()
1976            .unwrap();
1977        let counts = structs
1978            .column_by_name("count")
1979            .unwrap()
1980            .as_any()
1981            .downcast_ref::<UInt32Array>()
1982            .unwrap();
1983        let ids = structs
1984            .column_by_name("order_id")
1985            .unwrap()
1986            .as_any()
1987            .downcast_ref::<UInt64Array>()
1988            .unwrap();
1989        let asks = display
1990            .column_by_name("asks")
1991            .unwrap()
1992            .as_any()
1993            .downcast_ref::<ListArray>()
1994            .unwrap();
1995        let empty = catalog_record_batch_to_display(
1996            &NautilusDataType::OrderBookDepth,
1997            &metadata,
1998            &raw.slice(0, 0),
1999        )
2000        .unwrap();
2001
2002        assert_eq!(
2003            display.schema().as_ref(),
2004            &crate::arrow::schema_with_identifier_column(&depth_schema())
2005        );
2006        assert_eq!(empty.schema(), display.schema());
2007        assert_eq!(empty.num_rows(), 0);
2008        assert_eq!(display.num_rows(), 1);
2009        assert_eq!(list.value_length(0), i32::try_from(levels).unwrap());
2010        assert_eq!(asks.value_length(0), 0);
2011        assert!(!asks.is_null(0));
2012
2013        for i in 0..levels {
2014            assert_eq!(
2015                prices.value(i),
2016                f64::from(u32::try_from(100 + i).unwrap()) + 0.25
2017            );
2018            assert_eq!(
2019                sizes.value(i),
2020                f64::from(u32::try_from(200 + i).unwrap()) + 0.5
2021            );
2022            assert_eq!(counts.value(i), 300 + u32::try_from(i).unwrap());
2023            assert_eq!(ids.value(i), u64::MAX - u64::try_from(i).unwrap());
2024        }
2025    }
2026
2027    #[rstest]
2028    #[case(false, false)]
2029    #[case(false, true)]
2030    #[case(true, false)]
2031    #[case(true, true)]
2032    fn test_depth_display_rejects_legacy_shapes(#[case] lists: bool, #[case] with_ids: bool) {
2033        let depth = stub_depth10();
2034        let metadata = depth.metadata();
2035        let raw = OrderBookDepth::encode_batch(&metadata, &[depth]).unwrap();
2036        let mut columns = Vec::<ArrayRef>::new();
2037        let mut fields = Vec::<Field>::new();
2038
2039        for (side, prefix) in [("bids", "bid"), ("asks", "ask")] {
2040            let list = raw
2041                .column_by_name(side)
2042                .unwrap()
2043                .as_any()
2044                .downcast_ref::<ListArray>()
2045                .unwrap();
2046            let levels = list
2047                .values()
2048                .as_any()
2049                .downcast_ref::<StructArray>()
2050                .unwrap();
2051
2052            for name in ["price", "size", "count", "order_id"] {
2053                if name == "order_id" && !with_ids {
2054                    continue;
2055                }
2056                let values = levels.column_by_name(name).unwrap();
2057                if lists {
2058                    let item = Arc::new(Field::new("item", values.data_type().clone(), true));
2059                    let column =
2060                        FixedSizeListArray::try_new(item, 10, values.clone(), None).unwrap();
2061                    fields.push(Field::new(
2062                        format!("{prefix}_{name}"),
2063                        column.data_type().clone(),
2064                        false,
2065                    ));
2066                    columns.push(Arc::new(column));
2067                } else {
2068                    for i in 0..10 {
2069                        fields.push(Field::new(
2070                            format!("{prefix}_{name}_{i}"),
2071                            values.data_type().clone(),
2072                            true,
2073                        ));
2074                        columns.push(values.slice(i, 1));
2075                    }
2076                }
2077            }
2078        }
2079
2080        for name in ["flags", "sequence", "ts_event", "ts_init"] {
2081            fields.push(raw.schema().field_with_name(name).unwrap().clone());
2082            columns.push(raw.column_by_name(name).unwrap().clone());
2083        }
2084        let legacy = RecordBatch::try_new(
2085            Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
2086            columns,
2087        )
2088        .unwrap();
2089        let error =
2090            catalog_record_batch_to_display(&NautilusDataType::OrderBookDepth, &metadata, &legacy)
2091                .unwrap_err();
2092        assert!(
2093            matches!(error, EncodingError::ParseError("bids", ref message) if message == "expected List<Struct>")
2094        );
2095    }
2096
2097    #[rstest]
2098    fn test_catalog_display_schema_uses_data_type() {
2099        let schema = catalog_display_schema(&NautilusDataType::QuoteTick).unwrap();
2100
2101        assert_eq!(schema.fields()[0].name(), "instrument_id");
2102        assert_eq!(schema.fields()[1].data_type(), &DataType::Float64);
2103        assert_eq!(schema.fields().last().unwrap().name(), "ts_init");
2104    }
2105
2106    #[rstest]
2107    fn test_custom_catalog_display_schema_uses_registered_schema() {
2108        let type_name = "CatalogDisplaySchemaCustom";
2109        let schema = Schema::new(vec![
2110            Field::new("value", DataType::Float64, false),
2111            Field::new(
2112                "ts_init",
2113                DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
2114                false,
2115            ),
2116        ]);
2117        ensure_arrow_registered(
2118            type_name,
2119            Arc::new(schema),
2120            Box::new(|_| unreachable!()),
2121            Box::new(|_, _| unreachable!()),
2122        )
2123        .unwrap();
2124
2125        let data_type = NautilusDataType::Custom {
2126            type_name: type_name.to_string(),
2127        };
2128        let raw_schema = catalog_raw_schema(&data_type).unwrap();
2129        let schema = catalog_display_schema(&data_type).unwrap();
2130        let fields = schema
2131            .fields()
2132            .iter()
2133            .map(|field| (field.name().as_str(), field.data_type()))
2134            .collect::<Vec<_>>();
2135
2136        assert_eq!(
2137            fields,
2138            [
2139                ("value", &DataType::Float64),
2140                (
2141                    "ts_init",
2142                    &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
2143                ),
2144                ("data_type", &DataType::Utf8),
2145                ("identifier", &DataType::Utf8),
2146            ]
2147        );
2148        assert_eq!(raw_schema.fields().last().unwrap().name(), "identifier");
2149    }
2150
2151    #[rstest]
2152    fn test_custom_catalog_schema_rejects_unregistered_type() {
2153        let data_type = NautilusDataType::Custom {
2154            type_name: "UnregisteredCatalogDisplayCustom".to_string(),
2155        };
2156
2157        assert!(catalog_raw_schema(&data_type).is_err());
2158        assert!(catalog_display_schema(&data_type).is_err());
2159    }
2160
2161    #[rstest]
2162    fn test_custom_display_normalizes_utf8_view_to_utf8() {
2163        let batch = RecordBatch::try_new(
2164            Arc::new(Schema::new(vec![Field::new(
2165                "label",
2166                DataType::Utf8View,
2167                false,
2168            )])),
2169            vec![Arc::new(StringViewArray::from(vec!["value"]))],
2170        )
2171        .unwrap();
2172        let display = catalog_record_batch_to_display(
2173            &NautilusDataType::Custom {
2174                type_name: "ViewCustom".to_string(),
2175            },
2176            batch.schema().metadata(),
2177            &batch,
2178        )
2179        .unwrap();
2180
2181        assert_eq!(display.schema().field(0).data_type(), &DataType::Utf8);
2182        assert_eq!(
2183            display
2184                .column(0)
2185                .as_any()
2186                .downcast_ref::<StringArray>()
2187                .unwrap()
2188                .value(0),
2189            "value"
2190        );
2191    }
2192
2193    #[rstest]
2194    fn test_catalog_record_batch_to_display_converts_funding_rates() {
2195        let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
2196        let funding_rate = FundingRateUpdate::new(
2197            instrument_id,
2198            Decimal::from_str("0.000125").unwrap(),
2199            Some(480),
2200            None,
2201            UnixNanos::from(10),
2202            UnixNanos::from(11),
2203        );
2204        let metadata = funding_rate.metadata();
2205        let raw_batch = FundingRateUpdate::encode_batch(&metadata, &[funding_rate]).unwrap();
2206
2207        let display_batch = catalog_record_batch_to_display(
2208            &NautilusDataType::FundingRateUpdate,
2209            &metadata,
2210            &raw_batch,
2211        )
2212        .unwrap();
2213
2214        let mut expected_fields = funding_rate_schema().fields().to_vec();
2215        expected_fields.push(Arc::new(utf8_field(KEY_IDENTIFIER, true)));
2216        assert_eq!(
2217            display_batch.schema(),
2218            Arc::new(Schema::new(expected_fields))
2219        );
2220        let instrument_ids = display_batch
2221            .column(0)
2222            .as_any()
2223            .downcast_ref::<StringArray>()
2224            .unwrap();
2225        let rates = display_batch
2226            .column(1)
2227            .as_any()
2228            .downcast_ref::<Float64Array>()
2229            .unwrap();
2230        let intervals = display_batch
2231            .column(2)
2232            .as_any()
2233            .downcast_ref::<UInt64Array>()
2234            .unwrap();
2235        let next_funding_ns = display_batch
2236            .column(3)
2237            .as_any()
2238            .downcast_ref::<TimestampNanosecondArray>()
2239            .unwrap();
2240        let identifiers = display_batch
2241            .column(6)
2242            .as_any()
2243            .downcast_ref::<StringArray>()
2244            .unwrap();
2245
2246        assert_eq!(instrument_ids.value(0), "BTCUSDT-PERP.BINANCE");
2247        assert_eq!(rates.value(0), 0.000_125);
2248        assert_eq!(intervals.value(0), 480);
2249        assert!(next_funding_ns.is_null(0));
2250        assert_eq!(identifiers.value(0), "BTCUSDT-PERP.BINANCE");
2251    }
2252
2253    #[rstest]
2254    fn test_catalog_record_batch_to_display_rejects_precision_mismatch() {
2255        let metadata = HashMap::from([
2256            (KEY_INSTRUMENT_ID.to_string(), "AUD/USD.SIM".to_string()),
2257            (KEY_PRICE_PRECISION.to_string(), "5".to_string()),
2258            (KEY_SIZE_PRECISION.to_string(), "0".to_string()),
2259        ]);
2260        let schema = Arc::new(Schema::new_with_metadata(
2261            vec![
2262                Field::new("bid_price", DataType::FixedSizeBinary(1), false),
2263                Field::new("ask_price", DataType::FixedSizeBinary(1), false),
2264                Field::new("bid_size", DataType::FixedSizeBinary(1), false),
2265                Field::new("ask_size", DataType::FixedSizeBinary(1), false),
2266                Field::new("ts_event", DataType::UInt64, false),
2267                Field::new("ts_init", DataType::UInt64, false),
2268            ],
2269            metadata.clone(),
2270        ));
2271        let batch = RecordBatch::try_new(
2272            schema,
2273            vec![
2274                Arc::new(
2275                    FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2276                        vec![Some([0_u8].as_slice())].into_iter(),
2277                        1,
2278                    )
2279                    .unwrap(),
2280                ),
2281                Arc::new(
2282                    FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2283                        vec![Some([0_u8].as_slice())].into_iter(),
2284                        1,
2285                    )
2286                    .unwrap(),
2287                ),
2288                Arc::new(
2289                    FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2290                        vec![Some([0_u8].as_slice())].into_iter(),
2291                        1,
2292                    )
2293                    .unwrap(),
2294                ),
2295                Arc::new(
2296                    FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2297                        vec![Some([0_u8].as_slice())].into_iter(),
2298                        1,
2299                    )
2300                    .unwrap(),
2301                ),
2302                Arc::new(UInt64Array::from(vec![1])),
2303                Arc::new(UInt64Array::from(vec![1])),
2304            ],
2305        )
2306        .unwrap();
2307
2308        let error =
2309            catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &batch)
2310                .unwrap_err();
2311
2312        assert!(matches!(
2313            error,
2314            EncodingError::InvalidColumnType(
2315                "bid_price",
2316                _,
2317                DataType::Decimal128(38, 16),
2318                DataType::FixedSizeBinary(1)
2319            )
2320        ));
2321    }
2322
2323    #[cfg(feature = "arrow-display")]
2324    #[rstest]
2325    #[case::bar(NautilusDataType::Bar)]
2326    #[case::trade(NautilusDataType::TradeTick)]
2327    #[case::delta(NautilusDataType::OrderBookDelta)]
2328    #[case::close(NautilusDataType::InstrumentClose)]
2329    fn test_catalog_display_matches_typed_encoder(#[case] data_type: NautilusDataType) {
2330        let (raw, expected) = match data_type {
2331            NautilusDataType::Bar => {
2332                let value = stub_bar();
2333                (
2334                    Bar::encode_batch(&value.metadata(), &[value]).unwrap(),
2335                    encode_bars(&[value]).unwrap(),
2336                )
2337            }
2338            NautilusDataType::TradeTick => {
2339                let value = stub_trade_ethusdt_buy();
2340                (
2341                    TradeTick::encode_batch(&value.metadata(), &[value]).unwrap(),
2342                    encode_trades(&[value]).unwrap(),
2343                )
2344            }
2345            NautilusDataType::OrderBookDelta => {
2346                let value = stub_delta();
2347                (
2348                    OrderBookDelta::encode_batch(&value.metadata(), &[value]).unwrap(),
2349                    encode_deltas(&[value]).unwrap(),
2350                )
2351            }
2352            NautilusDataType::InstrumentClose => {
2353                let value = stub_instrument_close();
2354                (
2355                    InstrumentClose::encode_batch(&value.metadata(), &[value]).unwrap(),
2356                    encode_instrument_closes(&[value]).unwrap(),
2357                )
2358            }
2359            _ => unreachable!(),
2360        };
2361
2362        let displayed =
2363            catalog_record_batch_to_display(&data_type, raw.schema().metadata(), &raw).unwrap();
2364        let identifier = displayed.column_by_name(KEY_IDENTIFIER).unwrap().clone();
2365        let without_identifier = record_batch_without_identifier_column(displayed).unwrap();
2366
2367        assert_eq!(&identifier, raw.column_by_name(KEY_IDENTIFIER).unwrap());
2368        assert_eq!(without_identifier, expected);
2369    }
2370}