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