Skip to main content

nautilus_serialization/arrow/
mod.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//! Defines the Apache Arrow schema for Nautilus types.
17
18pub mod account_state;
19pub mod bar;
20pub mod catalog_display;
21pub mod close;
22pub mod custom;
23pub mod delta;
24pub mod depth;
25pub mod funding;
26pub mod greeks;
27pub mod index_price;
28pub mod instrument;
29pub mod instrument_status;
30pub mod json;
31pub mod legacy;
32pub mod mark_price;
33pub mod option_greeks;
34pub mod order_event;
35pub mod position_event;
36pub mod quote;
37pub mod report;
38pub mod snapshot;
39pub mod trade;
40
41#[cfg(feature = "arrow-display")]
42pub mod display;
43
44mod depth_display;
45mod display_conversion;
46
47#[cfg(test)]
48pub(crate) mod test_support;
49
50use std::{
51    borrow::Borrow,
52    collections::HashMap,
53    fmt::{Display, Write as FmtWrite},
54    io::{self, Write},
55    str::FromStr,
56    sync::Arc,
57};
58
59use arrow::{
60    array::{
61        Array, ArrayRef, BinaryArray, BinaryViewArray, Decimal128Array, DictionaryArray,
62        Int32Array, Int64Array, StringArray, StringBuilder, StringDictionaryBuilder,
63        StringViewArray, StructArray, TimestampNanosecondArray, UInt8Array, UInt32Array,
64        UInt64Array,
65    },
66    buffer::NullBuffer,
67    datatypes::{DataType, Field, Int8Type, Int32Type, Schema, TimeUnit},
68    error::ArrowError,
69    ipc::writer::StreamWriter,
70    record_batch::RecordBatch,
71};
72use nautilus_core::UnixNanos;
73use nautilus_model::{
74    data::{
75        Data, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, bar::Bar,
76        close::InstrumentClose, delta::OrderBookDelta, depth::OrderBookDepth,
77        option_chain::OptionGreeks, quote::QuoteTick, trade::TradeTick,
78    },
79    enums::BookAction,
80    identifiers::InstrumentId,
81    types::{
82        Currency, Money, PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity,
83        fixed::{FIXED_PRECISION, FIXED_PRECISION_STANDARD},
84        money::MoneyRaw,
85        price::PriceRaw,
86        quantity::{QUANTITY_RAW_MAX, QuantityRaw},
87    },
88};
89#[cfg(feature = "python")]
90use pyo3::prelude::*;
91use rust_decimal::Decimal;
92use ustr::Ustr;
93
94pub use self::legacy::{
95    is_nautilus_legacy_schema, is_nautilus_timestamp_schema, is_timestamp_field,
96    normalize_legacy_fixed_columns, normalized_legacy_data_type, normalized_timestamp_type,
97};
98
99// Define metadata key constants
100pub const KEY_BAR_TYPE: &str = "bar_type";
101pub const KEY_IDENTIFIER: &str = "identifier";
102pub const KEY_INSTRUMENT_ID: &str = "instrument_id";
103pub const KEY_PRICE_PRECISION: &str = "price_precision";
104pub const KEY_SIZE_PRECISION: &str = "size_precision";
105
106pub(crate) fn parse_metadata(
107    metadata: &HashMap<String, String>,
108) -> Result<(InstrumentId, u8, u8), EncodingError> {
109    let instrument_id = metadata
110        .get(KEY_INSTRUMENT_ID)
111        .ok_or(EncodingError::MissingMetadata(KEY_INSTRUMENT_ID))?
112        .parse::<InstrumentId>()
113        .map_err(|e| EncodingError::ParseError(KEY_INSTRUMENT_ID, e.to_string()))?;
114    let price_precision = metadata
115        .get(KEY_PRICE_PRECISION)
116        .ok_or(EncodingError::MissingMetadata(KEY_PRICE_PRECISION))?
117        .parse::<u8>()
118        .map_err(|e| EncodingError::ParseError(KEY_PRICE_PRECISION, e.to_string()))?;
119    let size_precision = metadata
120        .get(KEY_SIZE_PRECISION)
121        .ok_or(EncodingError::MissingMetadata(KEY_SIZE_PRECISION))?
122        .parse::<u8>()
123        .map_err(|e| EncodingError::ParseError(KEY_SIZE_PRECISION, e.to_string()))?;
124    Ok((instrument_id, price_precision, size_precision))
125}
126pub const KEY_TYPE_NAME: &str = "type_name";
127pub const FIXED_DECIMAL_PRECISION: u8 = 38;
128pub const FIXED_DECIMAL_SCALE: i8 = 16;
129pub(crate) const EMPTY_DEPTH_PRECISION: (u8, u8) = (0, 0);
130
131/// Returns `metadata` with the catalog type discriminator set to `type_name`.
132#[must_use]
133pub fn metadata_with_type_name(
134    type_name: &str,
135    metadata: Option<HashMap<String, String>>,
136) -> HashMap<String, String> {
137    let mut metadata = metadata.unwrap_or_default();
138    metadata.insert(KEY_TYPE_NAME.to_string(), type_name.to_string());
139    metadata
140}
141
142/// Returns the open Arrow data type used for nanosecond instants.
143#[must_use]
144pub fn timestamp_data_type() -> DataType {
145    DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
146}
147
148/// Builds a UTC nanosecond timestamp array from model timestamp values.
149///
150/// # Errors
151///
152/// Returns an error if a value exceeds Arrow's signed timestamp range.
153pub fn timestamp_array(
154    values: impl IntoIterator<Item = u64>,
155) -> Result<TimestampNanosecondArray, ArrowError> {
156    optional_timestamp_array(values.into_iter().map(Some))
157}
158
159/// Builds a nullable UTC nanosecond timestamp array from model timestamp values.
160///
161/// # Errors
162///
163/// Returns an error if a value exceeds Arrow's signed timestamp range.
164pub fn optional_timestamp_array(
165    values: impl IntoIterator<Item = Option<u64>>,
166) -> Result<TimestampNanosecondArray, ArrowError> {
167    let values = values
168        .into_iter()
169        .map(|value| {
170            value
171                .map(|value| {
172                    i64::try_from(value).map_err(|_| {
173                        ArrowError::InvalidArgumentError(format!(
174                            "Nanosecond timestamp {value} exceeds Arrow's signed timestamp range"
175                        ))
176                    })
177                })
178                .transpose()
179        })
180        .collect::<Result<Vec<_>, _>>()?;
181    Ok(TimestampNanosecondArray::from(values).with_data_type(timestamp_data_type()))
182}
183
184/// Reads a non-negative Arrow nanosecond timestamp as the model's unsigned representation.
185///
186/// # Errors
187///
188/// Returns an error if the value is negative.
189pub fn decode_timestamp(
190    values: &TimestampNanosecondArray,
191    name: &'static str,
192    row: usize,
193) -> Result<u64, EncodingError> {
194    u64::try_from(values.value(row)).map_err(|_| {
195        EncodingError::ParseError(
196            name,
197            format!(
198                "row {row}: negative nanosecond timestamp {}",
199                values.value(row)
200            ),
201        )
202    })
203}
204
205/// Builds a record batch, converting unsigned nanosecond inputs for timestamp schema fields.
206///
207/// This keeps model encoders simple while ensuring their public Arrow batches use logical
208/// timestamp columns.
209///
210/// # Errors
211///
212/// Returns an error if a timestamp exceeds the signed Arrow range or the batch is otherwise
213/// invalid.
214pub fn record_batch_with_timestamps(
215    schema: Arc<Schema>,
216    columns: Vec<ArrayRef>,
217) -> Result<RecordBatch, ArrowError> {
218    validate_encode_precisions(schema.metadata())?;
219
220    let columns = schema
221        .fields()
222        .iter()
223        .zip(columns)
224        .map(|(field, column)| {
225            if field.data_type() != &timestamp_data_type()
226                || column.data_type() != &DataType::UInt64
227            {
228                return Ok(column);
229            }
230            timestamp_column(field, column.as_ref())
231        })
232        .collect::<Result<Vec<_>, ArrowError>>()?;
233    RecordBatch::try_new(schema, columns)
234}
235
236// Rejects batch metadata whose precisions exceed the catalog's uniform decimal scale. Defi
237// precisions (for example wei at 17 or 18) store raws at their own native scale, so encoding
238// them bit-for-bit into `Decimal128(38, 16)` columns would inflate the externally visible
239// values; mirror the SBE and custom-data macro encode guards and fail the write instead.
240fn validate_encode_precisions(metadata: &HashMap<String, String>) -> Result<(), ArrowError> {
241    for key in [KEY_PRICE_PRECISION, KEY_SIZE_PRECISION] {
242        if let Some(value) = metadata.get(key)
243            && let Ok(precision) = value.parse::<u8>()
244            && precision > FIXED_DECIMAL_SCALE as u8
245        {
246            return Err(ArrowError::InvalidArgumentError(format!(
247                "Metadata '{key}' is {precision}, maximum supported catalog scale is {FIXED_DECIMAL_SCALE}"
248            )));
249        }
250    }
251    Ok(())
252}
253
254fn timestamp_column(field: &Field, column: &dyn Array) -> Result<ArrayRef, ArrowError> {
255    let values = column
256        .as_any()
257        .downcast_ref::<UInt64Array>()
258        .ok_or_else(|| ArrowError::CastError(format!("Column '{}' is not UInt64", field.name())))?;
259    let timestamps = optional_timestamp_array(
260        (0..values.len()).map(|row| (!values.is_null(row)).then(|| values.value(row))),
261    )?;
262    Ok(Arc::new(timestamps) as ArrayRef)
263}
264
265/// Converts logical timestamp columns to unsigned nanoseconds for existing model decoders.
266///
267/// # Errors
268///
269/// Returns an error if timestamps are negative or arrays are invalid.
270pub fn record_batch_with_u64_timestamps(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
271    let mut changed = false;
272    let mut fields = Vec::with_capacity(batch.num_columns());
273    let mut columns = Vec::with_capacity(batch.num_columns());
274
275    for (field, column) in batch.schema().fields().iter().zip(batch.columns()) {
276        if field.data_type() != &timestamp_data_type() {
277            fields.push(Arc::clone(field));
278            columns.push(Arc::clone(column));
279            continue;
280        }
281        let values = column
282            .as_any()
283            .downcast_ref::<TimestampNanosecondArray>()
284            .ok_or_else(|| {
285                EncodingError::ParseError(
286                    "timestamp",
287                    format!("Column '{}' is not TimestampNanosecond", field.name()),
288                )
289            })?;
290        let values = (0..values.len())
291            .map(|row| {
292                if values.is_null(row) {
293                    Ok(None)
294                } else {
295                    decode_timestamp(values, "timestamp", row).map(Some)
296                }
297            })
298            .collect::<Result<Vec<_>, _>>()?;
299        fields.push(Arc::new(
300            field.as_ref().clone().with_data_type(DataType::UInt64),
301        ));
302        columns.push(Arc::new(UInt64Array::from(values)) as ArrayRef);
303        changed = true;
304    }
305
306    if !changed {
307        return Ok(batch.clone());
308    }
309    RecordBatch::try_new(
310        Arc::new(Schema::new_with_metadata(
311            fields,
312            batch.schema().metadata().clone(),
313        )),
314        columns,
315    )
316    .map_err(EncodingError::from)
317}
318
319/// Returns the open Arrow data type used for enum-valued catalog columns.
320#[must_use]
321pub fn enum_dictionary_data_type() -> DataType {
322    DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8))
323}
324
325/// Builds a compact dictionary array containing enum display names.
326///
327/// # Errors
328///
329/// Returns an error if the number of distinct values exceeds the `Int8` key range.
330pub fn enum_dictionary_array(
331    values: impl IntoIterator<Item = impl Display>,
332) -> Result<DictionaryArray<Int8Type>, ArrowError> {
333    let mut builder = StringDictionaryBuilder::<Int8Type>::new();
334    for value in values {
335        builder.append(value.to_string())?;
336    }
337    Ok(builder.finish())
338}
339
340/// Returns the open Arrow data type used for monetary values.
341#[must_use]
342pub fn money_data_type() -> DataType {
343    DataType::Struct(
344        vec![
345            Field::new("amount", fixed_decimal_data_type(), false),
346            Field::new(
347                "currency",
348                DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8)),
349                false,
350            ),
351        ]
352        .into(),
353    )
354}
355
356/// Builds a nullable struct array containing monetary amounts and currencies.
357///
358/// # Errors
359///
360/// Returns an error if an amount or dictionary value cannot be represented.
361pub fn money_array(
362    values: impl IntoIterator<Item = Option<Money>>,
363) -> Result<StructArray, ArrowError> {
364    let mut amounts = Vec::new();
365    let mut currencies = StringDictionaryBuilder::<Int32Type>::new();
366    let mut validity = Vec::new();
367
368    for value in values {
369        if let Some(value) = value {
370            if value.currency.precision > FIXED_DECIMAL_SCALE as u8 {
371                return Err(ArrowError::InvalidArgumentError(format!(
372                    "Money currency precision {} exceeds catalog scale {FIXED_DECIMAL_SCALE}",
373                    value.currency.precision,
374                )));
375            }
376
377            amounts.push(money_raw_to_decimal(value.raw()));
378            currencies.append(value.currency.to_string())?;
379            validity.push(true);
380        } else {
381            amounts.push(0);
382            currencies.append_null();
383            validity.push(false);
384        }
385    }
386    let amounts = Decimal128Array::from(amounts)
387        .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)?;
388    StructArray::try_new(
389        match money_data_type() {
390            DataType::Struct(fields) => fields,
391            _ => unreachable!("money data type is a struct"),
392        },
393        vec![Arc::new(amounts), Arc::new(currencies.finish())],
394        Some(NullBuffer::from(validity)),
395    )
396}
397
398/// Encodes a Rust decimal at the catalog's scale.
399///
400/// # Errors
401///
402/// Returns an error if the value has more than 16 decimal places or cannot be rescaled exactly. The
403/// error names `field`.
404pub fn decimal_to_arrow(value: &Decimal, field: &'static str) -> Result<i128, ArrowError> {
405    let value = value.normalize();
406    let scale = value.scale();
407    if scale > FIXED_DECIMAL_SCALE as u32 {
408        return Err(ArrowError::InvalidArgumentError(format!(
409            "Decimal field '{field}' has scale {scale}, maximum supported scale is {FIXED_DECIMAL_SCALE}"
410        )));
411    }
412    let rescaled = value
413        .mantissa()
414        .checked_mul(10_i128.pow(FIXED_DECIMAL_SCALE as u32 - scale))
415        .ok_or_else(|| {
416            ArrowError::InvalidArgumentError(format!(
417                "Decimal field '{field}' cannot be represented as Decimal128(38, 16)"
418            ))
419        })?;
420    let max = Decimal::MAX.mantissa();
421    if rescaled < -max || rescaled > max {
422        return Err(ArrowError::InvalidArgumentError(format!(
423            "Decimal field '{field}' exceeds the rust_decimal 96-bit range after rescaling"
424        )));
425    }
426    Ok(rescaled)
427}
428
429/// Decodes a Rust decimal from the catalog's scale.
430///
431/// # Errors
432///
433/// Returns an error if the value is NULL or outside `rust_decimal`'s range.
434pub fn decode_decimal(
435    values: &Decimal128Array,
436    field: &'static str,
437    row: usize,
438) -> Result<Decimal, EncodingError> {
439    if values.is_null(row) {
440        return Err(EncodingError::ParseError(
441            field,
442            format!("row {row}: required decimal is null"),
443        ));
444    }
445    Decimal::try_from_i128_with_scale(values.value(row), FIXED_DECIMAL_SCALE as u32)
446        .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
447}
448
449/// Decodes a monetary value from its Arrow struct representation.
450///
451/// # Errors
452///
453/// Returns an error if the struct is NULL, malformed, or outside the model range.
454pub fn decode_money(
455    values: &StructArray,
456    field: &'static str,
457    row: usize,
458) -> Result<Money, EncodingError> {
459    if values.is_null(row) {
460        return Err(EncodingError::ParseError(
461            field,
462            format!("row {row}: required money is null"),
463        ));
464    }
465    let amounts = values
466        .column_by_name("amount")
467        .and_then(|array| array.as_any().downcast_ref::<Decimal128Array>())
468        .ok_or_else(|| {
469            EncodingError::ParseError(field, "money amount must be Decimal128(38, 16)".to_string())
470        })?;
471    let currencies = values
472        .column_by_name("currency")
473        .and_then(|array| StringColumnRef::try_from_array(array.as_ref()))
474        .ok_or_else(|| {
475            EncodingError::ParseError(
476                field,
477                "money currency must be Dictionary<Int32, Utf8>".to_string(),
478            )
479        })?;
480    let raw = decimal_to_money_raw(amounts.value(row), field, row)?;
481    let currency_code = currencies.value(row);
482    let currency = Currency::from_str(currency_code).map_err(|e| {
483        EncodingError::ParseError(
484            field,
485            format!(
486                "row {row}: currency '{currency_code}' must be registered before decoding Money: {e}"
487            ),
488        )
489    })?;
490
491    if currency.precision > FIXED_DECIMAL_SCALE as u8 {
492        return Err(EncodingError::ParseError(
493            field,
494            format!(
495                "row {row}: Money currency precision {} exceeds catalog scale {FIXED_DECIMAL_SCALE}",
496                currency.precision,
497            ),
498        ));
499    }
500
501    Money::from_raw_checked(raw, currency)
502        .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
503}
504
505#[allow(
506    clippy::useless_conversion,
507    reason = "MoneyRaw is i64 or i128 depending on model feature unification"
508)]
509fn money_raw_to_decimal(raw: MoneyRaw) -> i128 {
510    let mut decimal = i128::from(raw);
511    if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
512        decimal *= STANDARD_TO_DECIMAL_SCALE;
513    }
514    decimal
515}
516
517fn decimal_to_money_raw(
518    value: i128,
519    field: &'static str,
520    row: usize,
521) -> Result<MoneyRaw, EncodingError> {
522    decimal_to_raw(value, field, row, "MoneyRaw")
523}
524
525const STANDARD_TO_DECIMAL_SCALE: i128 =
526    10_i128.pow((FIXED_DECIMAL_SCALE as u8 - FIXED_PRECISION_STANDARD) as u32);
527
528#[derive(thiserror::Error, Debug)]
529pub enum DataStreamingError {
530    #[error("I/O error: {0}")]
531    IoError(#[from] io::Error),
532    #[error("Arrow error: {0}")]
533    ArrowError(#[from] arrow::error::ArrowError),
534    #[cfg(feature = "python")]
535    #[error("Python error: {0}")]
536    PythonError(#[from] PyErr),
537}
538
539#[derive(thiserror::Error, Debug)]
540pub enum EncodingError {
541    #[error("Empty data")]
542    EmptyData,
543    #[error(
544        "Mixed metadata at row {index}; encode each instrument, bar type, or precision separately"
545    )]
546    MixedMetadata { index: usize },
547    #[error("Missing metadata key: `{0}`")]
548    MissingMetadata(&'static str),
549    #[error("Missing data column: `{0}` at index {1}")]
550    MissingColumn(&'static str, usize),
551    #[error("Error parsing `{0}`: {1}")]
552    ParseError(&'static str, String),
553    #[error("Invalid column type `{0}` at index {1}: expected {2}, found {3}")]
554    InvalidColumnType(&'static str, usize, DataType, DataType),
555    #[error("Arrow error: {0}")]
556    ArrowError(#[from] arrow::error::ArrowError),
557}
558
559/// Returns the open fixed-point Arrow data type used by catalog write schemas.
560#[must_use]
561pub const fn fixed_decimal_data_type() -> DataType {
562    DataType::Decimal128(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
563}
564
565/// Returns a UTF-8 field annotated with the canonical Arrow JSON extension.
566#[must_use]
567pub fn json_string_field(name: impl Into<String>, nullable: bool) -> Field {
568    Field::new(name, DataType::Utf8, nullable).with_metadata(HashMap::from([
569        ("ARROW:extension:name".to_string(), "arrow.json".to_string()),
570        ("ARROW:extension:metadata".to_string(), String::new()),
571    ]))
572}
573
574/// Returns whether a field carries the canonical Arrow JSON extension.
575#[must_use]
576pub fn is_json_string_field(field: &Field) -> bool {
577    field.extension_type_name() == Some("arrow.json")
578}
579
580/// Encodes a model price raw value at the catalog's uniform decimal scale.
581///
582/// # Errors
583///
584/// Returns an error if `raw` is [`PRICE_ERROR`].
585pub fn price_raw_to_decimal(
586    raw: PriceRaw,
587    field: &'static str,
588) -> Result<Option<i128>, ArrowError> {
589    if raw == PRICE_UNDEF {
590        return Ok(None);
591    }
592
593    if raw == PRICE_ERROR {
594        return Err(ArrowError::InvalidArgumentError(format!(
595            "Price field '{field}' contains PRICE_ERROR raw value {raw}"
596        )));
597    }
598
599    #[allow(
600        clippy::useless_conversion,
601        reason = "PriceRaw is i64 or i128 depending on model feature unification"
602    )]
603    let mut decimal = i128::from(raw);
604    if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
605        decimal *= STANDARD_TO_DECIMAL_SCALE;
606    }
607
608    Ok(Some(decimal))
609}
610
611/// Encodes a model quantity raw value at the catalog's uniform decimal scale.
612///
613/// # Errors
614///
615/// Returns an error if a non-sentinel quantity does not fit in Arrow's signed decimal
616/// representation.
617pub fn quantity_raw_to_decimal(
618    raw: QuantityRaw,
619    field: &'static str,
620) -> Result<Option<i128>, ArrowError> {
621    if raw == QUANTITY_UNDEF {
622        return Ok(None);
623    }
624
625    if raw > QUANTITY_RAW_MAX {
626        return Err(ArrowError::InvalidArgumentError(format!(
627            "Quantity field '{field}' raw value {raw} exceeds QUANTITY_RAW_MAX={QUANTITY_RAW_MAX}"
628        )));
629    }
630
631    #[allow(
632        clippy::unnecessary_fallible_conversions,
633        reason = "QuantityRaw is u64 or u128 depending on model feature unification"
634    )]
635    let mut decimal = i128::try_from(raw).map_err(|_| {
636        ArrowError::InvalidArgumentError(format!(
637            "Quantity field '{field}' raw value {raw} exceeds Decimal128 range"
638        ))
639    })?;
640
641    if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
642        decimal *= STANDARD_TO_DECIMAL_SCALE;
643    }
644
645    Ok(Some(decimal))
646}
647
648/// Builds a scale-16 decimal array from model price raw values.
649///
650/// # Errors
651///
652/// Returns an error if the values do not fit the declared decimal type.
653pub fn price_decimal_array(
654    values: impl IntoIterator<Item = PriceRaw>,
655    field: &'static str,
656) -> Result<Decimal128Array, ArrowError> {
657    let values = values
658        .into_iter()
659        .map(|raw| price_raw_to_decimal(raw, field))
660        .collect::<Result<Vec<_>, _>>()?;
661    Decimal128Array::from(values)
662        .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
663}
664
665/// Builds a scale-16 decimal array from model quantity raw values.
666///
667/// # Errors
668///
669/// Returns an error if a value does not fit or the declared decimal type is invalid.
670pub fn quantity_decimal_array(
671    values: impl IntoIterator<Item = QuantityRaw>,
672    field: &'static str,
673) -> Result<Decimal128Array, ArrowError> {
674    let values = values
675        .into_iter()
676        .map(|raw| quantity_raw_to_decimal(raw, field))
677        .collect::<Result<Vec<_>, _>>()?;
678    Decimal128Array::from(values)
679        .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
680}
681
682/// Builds a scale-16 decimal array from price raw values of a type with no NULL sentinel.
683///
684/// # Errors
685///
686/// Returns an error if a value is `PRICE_UNDEF` or does not fit the declared decimal type, so the
687/// required decoders can read back every written value.
688pub fn required_price_decimal_array(
689    values: impl IntoIterator<Item = PriceRaw>,
690    field: &'static str,
691) -> Result<Decimal128Array, ArrowError> {
692    let values = values
693        .into_iter()
694        .map(|raw| {
695            price_raw_to_decimal(raw, field)?.ok_or_else(|| {
696                ArrowError::InvalidArgumentError(format!(
697                    "Price field '{field}' contains PRICE_UNDEF, which has no sentinel encoding for this type"
698                ))
699            })
700        })
701        .collect::<Result<Vec<_>, _>>()?;
702    Decimal128Array::from(values)
703        .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
704}
705
706/// Builds a scale-16 decimal array from quantity raw values of a type with no NULL sentinel.
707///
708/// # Errors
709///
710/// Returns an error if a value is `QUANTITY_UNDEF` or does not fit the declared decimal type, so
711/// the required decoders can read back every written value.
712pub fn required_quantity_decimal_array(
713    values: impl IntoIterator<Item = QuantityRaw>,
714    field: &'static str,
715) -> Result<Decimal128Array, ArrowError> {
716    let values = values
717        .into_iter()
718        .map(|raw| {
719            quantity_raw_to_decimal(raw, field)?.ok_or_else(|| {
720                ArrowError::InvalidArgumentError(format!(
721                    "Quantity field '{field}' contains QUANTITY_UNDEF, which has no sentinel encoding for this type"
722                ))
723            })
724        })
725        .collect::<Result<Vec<_>, _>>()?;
726    Decimal128Array::from(values)
727        .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
728}
729
730fn decimal_to_price_raw(
731    value: i128,
732    field: &'static str,
733    row: usize,
734) -> Result<PriceRaw, EncodingError> {
735    decimal_to_raw(value, field, row, "PriceRaw")
736}
737
738fn decimal_to_quantity_raw(
739    value: i128,
740    field: &'static str,
741    row: usize,
742) -> Result<QuantityRaw, EncodingError> {
743    decimal_to_raw(value, field, row, "QuantityRaw")
744}
745
746fn decimal_to_raw<T: TryFrom<i128>>(
747    value: i128,
748    field: &'static str,
749    row: usize,
750    raw_type: &'static str,
751) -> Result<T, EncodingError> {
752    let raw_value = if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
753        if value % STANDARD_TO_DECIMAL_SCALE != 0 {
754            return Err(EncodingError::ParseError(
755                field,
756                format!(
757                    "row {row}: decimal value {value} has nonzero digits beyond build precision 9"
758                ),
759            ));
760        }
761        value / STANDARD_TO_DECIMAL_SCALE
762    } else {
763        value
764    };
765
766    T::try_from(raw_value).map_err(|_| {
767        EncodingError::ParseError(
768            field,
769            format!("row {row}: decimal value {value} exceeds {raw_type} range"),
770        )
771    })
772}
773
774/// Decodes a price from a nullable scale-16 decimal column.
775///
776/// # Errors
777///
778/// Returns an error if the value cannot be represented by this build.
779pub fn decode_decimal_price(
780    values: &Decimal128Array,
781    precision: u8,
782    field: &'static str,
783    row: usize,
784) -> Result<Price, EncodingError> {
785    if values.is_null(row) {
786        return Price::from_raw_checked(PRICE_UNDEF, 0)
787            .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")));
788    }
789
790    let raw = decimal_to_price_raw(values.value(row), field, row)?;
791    Price::from_raw_checked(raw, precision)
792        .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
793}
794
795/// Decodes a required price from a scale-16 decimal column.
796///
797/// # Errors
798///
799/// Returns an error if the value is null or cannot be represented by this build.
800pub fn decode_required_decimal_price(
801    values: &Decimal128Array,
802    precision: u8,
803    field: &'static str,
804    row: usize,
805) -> Result<Price, EncodingError> {
806    if values.is_null(row) {
807        return Err(EncodingError::ParseError(
808            field,
809            format!("row {row}: required price is null"),
810        ));
811    }
812    decode_decimal_price(values, precision, field, row)
813}
814
815/// Decodes a quantity from a nullable scale-16 decimal column.
816///
817/// # Errors
818///
819/// Returns an error if the value cannot be represented by this build.
820pub fn decode_decimal_quantity(
821    values: &Decimal128Array,
822    precision: u8,
823    field: &'static str,
824    row: usize,
825) -> Result<Quantity, EncodingError> {
826    if values.is_null(row) {
827        return Quantity::from_raw_checked(QUANTITY_UNDEF, 0)
828            .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")));
829    }
830
831    let raw = decimal_to_quantity_raw(values.value(row), field, row)?;
832    Quantity::from_raw_checked(raw, precision)
833        .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
834}
835
836/// Decodes a required quantity from a scale-16 decimal column.
837///
838/// # Errors
839///
840/// Returns an error if the value is null or cannot be represented by this build.
841pub fn decode_required_decimal_quantity(
842    values: &Decimal128Array,
843    precision: u8,
844    field: &'static str,
845    row: usize,
846) -> Result<Quantity, EncodingError> {
847    if values.is_null(row) {
848        return Err(EncodingError::ParseError(
849            field,
850            format!("row {row}: required quantity is null"),
851        ));
852    }
853    decode_decimal_quantity(values, precision, field, row)
854}
855
856/// Returns a required timestamp value, naming the field and row on NULL.
857///
858/// # Errors
859///
860/// Returns an error if the value is null.
861pub fn decode_required_timestamp(
862    values: &UInt64Array,
863    field: &'static str,
864    row: usize,
865) -> Result<UnixNanos, EncodingError> {
866    if values.is_null(row) {
867        return Err(EncodingError::ParseError(
868            field,
869            format!("row {row}: required timestamp is null"),
870        ));
871    }
872    Ok(values.value(row).into())
873}
874
875pub(crate) fn decode_required_u64(
876    values: &UInt64Array,
877    field: &'static str,
878    row: usize,
879) -> Result<u64, EncodingError> {
880    if values.is_null(row) {
881        return Err(EncodingError::ParseError(
882            field,
883            format!("row {row}: required integer is null"),
884        ));
885    }
886    Ok(values.value(row))
887}
888
889pub(crate) fn decode_required_u8(
890    values: &UInt8Array,
891    field: &'static str,
892    row: usize,
893) -> Result<u8, EncodingError> {
894    if values.is_null(row) {
895        return Err(EncodingError::ParseError(
896            field,
897            format!("row {row}: required integer is null"),
898        ));
899    }
900    Ok(values.value(row))
901}
902
903#[cfg(test)]
904fn get_raw_price(value: i128) -> PriceRaw {
905    decimal_to_price_raw(value, "test", 0).expect("Decimal price must fit the current build")
906}
907
908#[cfg(test)]
909fn get_raw_quantity(value: i128) -> QuantityRaw {
910    decimal_to_quantity_raw(value, "test", 0).expect("Decimal quantity must fit the current build")
911}
912
913/// Provides Apache Arrow schema definitions for data types.
914pub trait ArrowSchemaProvider {
915    /// Returns the Arrow schema for this type with optional metadata.
916    fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema;
917
918    /// Returns a map of field names to their Arrow data types.
919    #[must_use]
920    fn get_schema_map() -> HashMap<String, String> {
921        let schema = Self::get_schema(None);
922        let mut map = HashMap::new();
923
924        for field in schema.fields() {
925            let name = field.name().clone();
926            let data_type = format!("{:?}", field.data_type());
927            map.insert(name, data_type);
928        }
929        map
930    }
931}
932
933/// Encodes data types to Apache Arrow RecordBatch format.
934pub trait EncodeToRecordBatch
935where
936    Self: Sized + ArrowSchemaProvider,
937{
938    /// Encodes a batch of values into an Arrow `RecordBatch` using the provided metadata.
939    ///
940    /// # Errors
941    ///
942    /// Returns an error if the encoding fails.
943    fn encode_batch<T>(
944        metadata: &HashMap<String, String>,
945        data: &[T],
946    ) -> Result<RecordBatch, ArrowError>
947    where
948        T: Borrow<Self>;
949
950    /// Returns the metadata for this data element.
951    fn metadata(&self) -> HashMap<String, String>;
952
953    /// Returns the metadata selected for a chunk.
954    ///
955    /// The default uses the first element. Implementations may override this when leading sentinel
956    /// values do not carry meaningful metadata.
957    ///
958    /// # Panics
959    ///
960    /// Panics if `chunk` is empty.
961    fn chunk_metadata<T>(chunk: &[T]) -> HashMap<String, String>
962    where
963        T: Borrow<Self>,
964    {
965        chunk
966            .first()
967            .map(|item| item.borrow().metadata())
968            .expect("Chunk must contain at least one element to encode")
969    }
970
971    /// Returns whether this element is compatible with metadata selected for its chunk.
972    fn matches_chunk_metadata(&self, metadata: &HashMap<String, String>) -> bool {
973        self.metadata() == *metadata
974    }
975}
976
977/// Returns the catalog row identifier from Arrow schema metadata.
978///
979/// Bars use `bar_type`; all other built-in catalog types use `instrument_id`.
980/// Custom data can pass an explicit identifier to [`record_batch_with_identifier_column`].
981#[must_use]
982pub fn catalog_identifier_from_metadata(metadata: &HashMap<String, String>) -> Option<String> {
983    metadata
984        .get(KEY_BAR_TYPE)
985        .cloned()
986        .or_else(|| metadata.get(KEY_INSTRUMENT_ID).cloned())
987}
988
989/// Builds a schema with the catalog `identifier` column appended if absent.
990#[must_use]
991pub fn schema_with_identifier_column(schema: &Schema) -> Schema {
992    if schema.index_of(KEY_IDENTIFIER).is_ok() {
993        return schema.clone();
994    }
995
996    let mut fields = schema.fields().iter().cloned().collect::<Vec<_>>();
997    fields.push(Arc::new(Field::new(KEY_IDENTIFIER, DataType::Utf8, true)));
998
999    Schema::new_with_metadata(fields, schema.metadata().clone())
1000}
1001
1002/// Builds a schema without the catalog `identifier` column.
1003#[must_use]
1004pub fn schema_without_identifier_column(schema: &Schema) -> Schema {
1005    let Ok(identifier_index) = schema.index_of(KEY_IDENTIFIER) else {
1006        return schema.clone();
1007    };
1008
1009    let fields = schema
1010        .fields()
1011        .iter()
1012        .enumerate()
1013        .filter_map(|(index, field)| (index != identifier_index).then_some(Arc::clone(field)))
1014        .collect::<Vec<_>>();
1015
1016    Schema::new_with_metadata(fields, schema.metadata().clone())
1017}
1018
1019/// Adds the catalog `identifier` column used by table-oriented catalog storage.
1020///
1021/// The column is nullable so custom data without a `DataType.identifier()` can
1022/// still be stored in the same type table.
1023///
1024/// # Errors
1025///
1026/// Returns an error if the record batch cannot be rebuilt.
1027pub fn record_batch_with_identifier_column(
1028    batch: RecordBatch,
1029    identifier: Option<&str>,
1030) -> Result<RecordBatch, ArrowError> {
1031    let identifier_values = vec![identifier.map(ToString::to_string); batch.num_rows()];
1032    record_batch_with_identifier_values(batch, identifier_values)
1033}
1034
1035/// Adds the catalog `identifier` column with one identifier value per row.
1036///
1037/// # Errors
1038///
1039/// Returns an error if the number of identifier values differs from the record batch row count or
1040/// the record batch cannot be rebuilt.
1041pub fn record_batch_with_identifier_values(
1042    batch: RecordBatch,
1043    identifier_values: Vec<Option<String>>,
1044) -> Result<RecordBatch, ArrowError> {
1045    if batch.schema().index_of(KEY_IDENTIFIER).is_ok() {
1046        return Ok(batch);
1047    }
1048
1049    if identifier_values.len() != batch.num_rows() {
1050        return Err(ArrowError::InvalidArgumentError(format!(
1051            "identifier values length {} does not match record batch row count {}",
1052            identifier_values.len(),
1053            batch.num_rows()
1054        )));
1055    }
1056
1057    let schema = schema_with_identifier_column(batch.schema().as_ref());
1058    let mut columns = batch.columns().to_vec();
1059    columns.push(Arc::new(StringArray::from(identifier_values)));
1060
1061    RecordBatch::try_new(Arc::new(schema), columns)
1062}
1063
1064/// Builds a string array for catalog identifiers without collecting intermediate strings.
1065#[must_use]
1066pub fn identifier_array_from_display<T: Display>(
1067    identifiers: impl IntoIterator<Item = T>,
1068) -> StringArray {
1069    let mut builder = StringBuilder::new();
1070    let mut scratch = String::new();
1071
1072    for identifier in identifiers {
1073        scratch.clear();
1074        write!(&mut scratch, "{identifier}").expect("writing to String should not fail");
1075        builder.append_value(scratch.as_str());
1076    }
1077
1078    builder.finish()
1079}
1080
1081/// Drops the catalog `identifier` column when writing legacy per-identifier formats.
1082///
1083/// # Errors
1084///
1085/// Returns an error if the record batch cannot be rebuilt.
1086pub fn record_batch_without_identifier_column(
1087    mut batch: RecordBatch,
1088) -> Result<RecordBatch, ArrowError> {
1089    let Ok(identifier_index) = batch.schema().index_of(KEY_IDENTIFIER) else {
1090        return Ok(batch);
1091    };
1092
1093    batch.remove_column(identifier_index);
1094    Ok(batch)
1095}
1096
1097/// Decodes data types from Apache Arrow RecordBatch format.
1098pub trait DecodeFromRecordBatch
1099where
1100    Self: Sized + Into<Data> + ArrowSchemaProvider,
1101{
1102    /// Decodes a `RecordBatch` into a vector of values of the implementing type, using the provided metadata.
1103    ///
1104    /// # Errors
1105    ///
1106    /// Returns an error if the decoding fails.
1107    fn decode_batch(
1108        metadata: &HashMap<String, String>,
1109        record_batch: RecordBatch,
1110    ) -> Result<Vec<Self>, EncodingError>;
1111}
1112
1113/// Decodes strongly typed values from Apache Arrow RecordBatch format.
1114pub trait DecodeTypedFromRecordBatch
1115where
1116    Self: Sized + ArrowSchemaProvider,
1117{
1118    /// Decodes a `RecordBatch` into a vector of values of the implementing type.
1119    ///
1120    /// # Errors
1121    ///
1122    /// Returns an error if the decoding fails.
1123    fn decode_typed_batch(
1124        metadata: &HashMap<String, String>,
1125        record_batch: RecordBatch,
1126    ) -> Result<Vec<Self>, EncodingError>;
1127}
1128
1129impl<T> DecodeTypedFromRecordBatch for T
1130where
1131    T: DecodeFromRecordBatch,
1132{
1133    fn decode_typed_batch(
1134        metadata: &HashMap<String, String>,
1135        record_batch: RecordBatch,
1136    ) -> Result<Vec<Self>, EncodingError> {
1137        Self::decode_batch(metadata, record_batch)
1138    }
1139}
1140
1141/// Decodes raw Data objects from Apache Arrow RecordBatch format.
1142pub trait DecodeDataFromRecordBatch
1143where
1144    Self: Sized + ArrowSchemaProvider,
1145{
1146    /// Decodes a `RecordBatch` into raw `Data` values, using the provided metadata.
1147    ///
1148    /// # Errors
1149    ///
1150    /// Returns an error if the decoding fails.
1151    fn decode_data_batch(
1152        metadata: &HashMap<String, String>,
1153        record_batch: RecordBatch,
1154    ) -> Result<Vec<Data>, EncodingError>;
1155}
1156
1157/// Writes RecordBatch data to output streams.
1158pub trait WriteStream {
1159    /// Writes a `RecordBatch` to the implementing output stream.
1160    ///
1161    /// # Errors
1162    ///
1163    /// Returns an error if writing or finishing the stream fails.
1164    fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError>;
1165}
1166
1167impl<T: Write> WriteStream for T {
1168    fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError> {
1169        let mut writer = StreamWriter::try_new(self, &record_batch.schema())?;
1170        writer.write(record_batch)?;
1171        writer.finish()?;
1172        Ok(())
1173    }
1174}
1175
1176/// Extracts a string column, accepting both Utf8 (`StringArray`) and Utf8View (`StringViewArray`).
1177/// Parquet may return Utf8View when reading, so this handles both formats.
1178///
1179/// # Errors
1180///
1181/// Returns an error if:
1182/// - `column_index` is out of range.
1183/// - The column type is neither Utf8 nor Utf8View.
1184pub fn extract_column_string<'a>(
1185    cols: &'a [ArrayRef],
1186    column_key: &'static str,
1187    column_index: usize,
1188) -> Result<StringColumnRef<'a>, EncodingError> {
1189    let column_values = cols
1190        .get(column_index)
1191        .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1192    StringColumnRef::try_from_array(column_values.as_ref()).ok_or_else(|| {
1193        EncodingError::InvalidColumnType(
1194            column_key,
1195            column_index,
1196            DataType::Utf8,
1197            column_values.data_type().clone(),
1198        )
1199    })
1200}
1201
1202/// Reference to a string column in a supported Arrow string representation.
1203#[derive(Debug)]
1204pub enum StringColumnRef<'a> {
1205    DictionaryInt8(&'a DictionaryArray<Int8Type>, &'a StringArray),
1206    DictionaryInt32(&'a DictionaryArray<Int32Type>, &'a StringArray),
1207    Utf8(&'a StringArray),
1208    Utf8View(&'a StringViewArray),
1209}
1210
1211impl StringColumnRef<'_> {
1212    /// Returns the number of rows.
1213    #[must_use]
1214    pub fn len(&self) -> usize {
1215        match self {
1216            Self::DictionaryInt8(array, _) => array.len(),
1217            Self::DictionaryInt32(array, _) => array.len(),
1218            Self::Utf8(array) => array.len(),
1219            Self::Utf8View(array) => array.len(),
1220        }
1221    }
1222
1223    /// Returns whether the column contains no rows.
1224    #[must_use]
1225    pub fn is_empty(&self) -> bool {
1226        self.len() == 0
1227    }
1228
1229    /// Returns a string view when `array` uses `Utf8` or `Utf8View` encoding.
1230    #[must_use]
1231    pub fn try_from_array(array: &dyn Array) -> Option<StringColumnRef<'_>> {
1232        if let Some(array) = array.as_any().downcast_ref::<DictionaryArray<Int8Type>>()
1233            && let Some(values) = array.values().as_any().downcast_ref::<StringArray>()
1234        {
1235            return Some(StringColumnRef::DictionaryInt8(array, values));
1236        }
1237
1238        if let Some(array) = array.as_any().downcast_ref::<DictionaryArray<Int32Type>>()
1239            && let Some(values) = array.values().as_any().downcast_ref::<StringArray>()
1240        {
1241            return Some(StringColumnRef::DictionaryInt32(array, values));
1242        }
1243
1244        if let Some(array) = array.as_any().downcast_ref::<StringArray>() {
1245            return Some(StringColumnRef::Utf8(array));
1246        }
1247
1248        array
1249            .as_any()
1250            .downcast_ref::<StringViewArray>()
1251            .map(StringColumnRef::Utf8View)
1252    }
1253
1254    /// Returns the string value at row `i`.
1255    ///
1256    /// # Panics
1257    ///
1258    /// Panics if `i` is out of bounds or a dictionary column contains an invalid key.
1259    #[inline]
1260    #[must_use]
1261    pub fn value(&self, i: usize) -> &str {
1262        match self {
1263            Self::DictionaryInt8(array, values) => {
1264                let key = usize::try_from(array.keys().value(i)).expect("Int8 key is non-negative");
1265                values.value(key)
1266            }
1267            Self::DictionaryInt32(array, values) => {
1268                let key =
1269                    usize::try_from(array.keys().value(i)).expect("Int32 key is non-negative");
1270                values.value(key)
1271            }
1272            Self::Utf8(arr) => arr.value(i),
1273            Self::Utf8View(arr) => arr.value(i),
1274        }
1275    }
1276
1277    /// Returns whether the value at row `i` is null.
1278    #[inline]
1279    #[must_use]
1280    pub fn is_null(&self, i: usize) -> bool {
1281        match self {
1282            Self::DictionaryInt8(array, _) => array.is_null(i),
1283            Self::DictionaryInt32(array, _) => array.is_null(i),
1284            Self::Utf8(arr) => arr.is_null(i),
1285            Self::Utf8View(arr) => arr.is_null(i),
1286        }
1287    }
1288
1289    /// Returns the string value at row `i`, or `None` when it is null.
1290    #[must_use]
1291    pub fn value_opt(&self, i: usize) -> Option<&str> {
1292        (!self.is_null(i)).then(|| self.value(i))
1293    }
1294}
1295
1296/// Reference to an unsigned 64-bit value stored as an integer or UTC nanosecond timestamp.
1297#[derive(Debug)]
1298pub enum U64ColumnRef<'a> {
1299    UInt64(&'a UInt64Array),
1300    Int64(&'a Int64Array),
1301    TimestampNanosecond(&'a TimestampNanosecondArray),
1302}
1303
1304impl U64ColumnRef<'_> {
1305    /// Returns a compatible unsigned 64-bit view for `array`.
1306    #[must_use]
1307    pub fn try_from_array(array: &dyn Array) -> Option<U64ColumnRef<'_>> {
1308        if let Some(array) = array.as_any().downcast_ref::<UInt64Array>() {
1309            return Some(U64ColumnRef::UInt64(array));
1310        }
1311
1312        if let Some(array) = array.as_any().downcast_ref::<Int64Array>() {
1313            return Some(U64ColumnRef::Int64(array));
1314        }
1315
1316        array
1317            .as_any()
1318            .downcast_ref::<TimestampNanosecondArray>()
1319            .map(U64ColumnRef::TimestampNanosecond)
1320    }
1321
1322    /// Returns the number of values in the column.
1323    #[must_use]
1324    pub fn len(&self) -> usize {
1325        match self {
1326            Self::UInt64(array) => array.len(),
1327            Self::Int64(array) => array.len(),
1328            Self::TimestampNanosecond(array) => array.len(),
1329        }
1330    }
1331
1332    /// Returns whether the column has no values.
1333    #[must_use]
1334    pub fn is_empty(&self) -> bool {
1335        self.len() == 0
1336    }
1337
1338    /// Returns whether the value at row `i` is null.
1339    #[must_use]
1340    pub fn is_null(&self, i: usize) -> bool {
1341        match self {
1342            Self::UInt64(array) => array.is_null(i),
1343            Self::Int64(array) => array.is_null(i),
1344            Self::TimestampNanosecond(array) => array.is_null(i),
1345        }
1346    }
1347
1348    /// Returns the value at row `i`, or `None` when a signed representation is negative.
1349    #[must_use]
1350    pub fn value(&self, i: usize) -> Option<u64> {
1351        match self {
1352            Self::UInt64(array) => Some(array.value(i)),
1353            Self::Int64(array) => u64::try_from(array.value(i)).ok(),
1354            Self::TimestampNanosecond(array) => u64::try_from(array.value(i)).ok(),
1355        }
1356    }
1357}
1358
1359/// Reference to an unsigned 32-bit column stored as `UInt32`, `Int32`, or `Int64`.
1360#[derive(Debug)]
1361pub enum U32ColumnRef<'a> {
1362    UInt32(&'a UInt32Array),
1363    Int32(&'a Int32Array),
1364    Int64(&'a Int64Array),
1365}
1366
1367impl U32ColumnRef<'_> {
1368    /// Returns a compatible unsigned 32-bit view for `array`.
1369    #[must_use]
1370    pub fn try_from_array(array: &dyn Array) -> Option<U32ColumnRef<'_>> {
1371        if let Some(array) = array.as_any().downcast_ref::<UInt32Array>() {
1372            return Some(U32ColumnRef::UInt32(array));
1373        }
1374
1375        if let Some(array) = array.as_any().downcast_ref::<Int32Array>() {
1376            return Some(U32ColumnRef::Int32(array));
1377        }
1378
1379        array
1380            .as_any()
1381            .downcast_ref::<Int64Array>()
1382            .map(U32ColumnRef::Int64)
1383    }
1384}
1385
1386/// Extracts a binary column, accepting both Binary (`BinaryArray`) and BinaryView
1387/// (`BinaryViewArray`).
1388/// DataFusion may return BinaryView when reading Parquet, so this handles both formats.
1389///
1390/// # Errors
1391///
1392/// Returns an error if:
1393/// - `column_index` is out of range.
1394/// - The column type is neither Binary nor BinaryView.
1395pub fn extract_column_binary<'a>(
1396    cols: &'a [ArrayRef],
1397    column_key: &'static str,
1398    column_index: usize,
1399) -> Result<BinaryColumnRef<'a>, EncodingError> {
1400    let column_values = cols
1401        .get(column_index)
1402        .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1403    let dt = column_values.data_type();
1404    if let Some(arr) = column_values.as_any().downcast_ref::<BinaryArray>() {
1405        Ok(BinaryColumnRef::Binary(arr))
1406    } else if let Some(arr) = column_values.as_any().downcast_ref::<BinaryViewArray>() {
1407        Ok(BinaryColumnRef::BinaryView(arr))
1408    } else {
1409        Err(EncodingError::InvalidColumnType(
1410            column_key,
1411            column_index,
1412            DataType::Binary,
1413            dt.clone(),
1414        ))
1415    }
1416}
1417
1418/// Reference to a binary column, either Binary or BinaryView.
1419#[derive(Debug)]
1420pub enum BinaryColumnRef<'a> {
1421    Binary(&'a BinaryArray),
1422    BinaryView(&'a BinaryViewArray),
1423}
1424
1425impl BinaryColumnRef<'_> {
1426    /// Returns whether the row contains a null value.
1427    #[inline]
1428    #[must_use]
1429    pub fn is_null(&self, i: usize) -> bool {
1430        match self {
1431            Self::Binary(arr) => arr.is_null(i),
1432            Self::BinaryView(arr) => arr.is_null(i),
1433        }
1434    }
1435
1436    /// Returns the bytes at row `i`.
1437    #[inline]
1438    #[must_use]
1439    pub fn value(&self, i: usize) -> &[u8] {
1440        match self {
1441            Self::Binary(arr) => arr.value(i),
1442            Self::BinaryView(arr) => arr.value(i),
1443        }
1444    }
1445}
1446
1447/// Extracts and downcasts the specified `column_key` column from an Arrow array slice.
1448///
1449/// # Errors
1450///
1451/// Returns an error if:
1452/// - `column_index` is out of range.
1453/// - The column type does not match `expected_type`.
1454pub fn extract_column<'a, T: Array + 'static>(
1455    cols: &'a [ArrayRef],
1456    column_key: &'static str,
1457    column_index: usize,
1458    expected_type: DataType,
1459) -> Result<&'a T, EncodingError> {
1460    let column_values = cols
1461        .get(column_index)
1462        .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1463
1464    let downcasted_values = column_values
1465        .as_any()
1466        .downcast_ref::<T>()
1467        .filter(|_| column_values.data_type() == &expected_type)
1468        .ok_or(EncodingError::InvalidColumnType(
1469            column_key,
1470            column_index,
1471            expected_type,
1472            column_values.data_type().clone(),
1473        ))?;
1474    Ok(downcasted_values)
1475}
1476
1477/// Extracts a column by name.
1478///
1479/// # Errors
1480///
1481/// Returns an error if the column is missing or has the wrong type.
1482pub fn extract_column_by_name<'a, T: Array + 'static>(
1483    record_batch: &'a RecordBatch,
1484    column_key: &'static str,
1485    expected_type: DataType,
1486) -> Result<&'a T, EncodingError> {
1487    let column_index = record_batch.schema().index_of(column_key)?;
1488    extract_column::<T>(
1489        record_batch.columns(),
1490        column_key,
1491        column_index,
1492        expected_type,
1493    )
1494}
1495
1496/// Extracts a decimal column by its schema name.
1497///
1498/// # Errors
1499///
1500/// Returns an error if the named column is missing or is not `Decimal128(38, 16)`.
1501pub fn extract_decimal_column<'a>(
1502    record_batch: &'a RecordBatch,
1503    column_key: &'static str,
1504) -> Result<&'a Decimal128Array, EncodingError> {
1505    let column_index = record_batch.schema().index_of(column_key)?;
1506    extract_column(
1507        record_batch.columns(),
1508        column_key,
1509        column_index,
1510        fixed_decimal_data_type(),
1511    )
1512}
1513
1514/// Extracts an optional UTF-8 column by name.
1515///
1516/// # Errors
1517///
1518/// Returns an error if the column exists but is not UTF-8.
1519pub fn extract_optional_string_column_by_name<'a>(
1520    record_batch: &'a RecordBatch,
1521    column_key: &'static str,
1522) -> Result<Option<&'a StringArray>, EncodingError> {
1523    let Ok(column_index) = record_batch.schema().index_of(column_key) else {
1524        return Ok(None);
1525    };
1526    let column_values = record_batch
1527        .columns()
1528        .get(column_index)
1529        .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1530    let downcasted_values = column_values.as_any().downcast_ref::<StringArray>().ok_or(
1531        EncodingError::InvalidColumnType(
1532            column_key,
1533            column_index,
1534            DataType::Utf8,
1535            column_values.data_type().clone(),
1536        ),
1537    )?;
1538    Ok(Some(downcasted_values))
1539}
1540
1541/// Returns an optional [`Ustr`] value from an optional string column.
1542#[must_use]
1543pub fn optional_ustr_value(values: Option<&StringArray>, row: usize) -> Option<Ustr> {
1544    values.and_then(|column| (!column.is_null(row)).then(|| Ustr::from(column.value(row))))
1545}
1546
1547/// Converts a vector of `OrderBookDelta` into an Arrow `RecordBatch`.
1548///
1549/// # Errors
1550///
1551/// Returns an error if:
1552/// - `data` is empty.
1553/// - Instrument IDs differ, or non-clear precision metadata differs.
1554/// - Encoding fails.
1555pub fn book_deltas_to_arrow_record_batch_bytes(
1556    data: &[OrderBookDelta],
1557) -> Result<RecordBatch, EncodingError> {
1558    let Some(first) = data.first() else {
1559        return Err(EncodingError::EmptyData);
1560    };
1561
1562    let metadata = OrderBookDelta::chunk_metadata(data);
1563    let instrument_id = data
1564        .iter()
1565        .find(|delta| delta.action != BookAction::Clear)
1566        .unwrap_or(first)
1567        .instrument_id;
1568
1569    if let Some(index) = data.iter().position(|delta| {
1570        delta.instrument_id != instrument_id
1571            || (delta.action != BookAction::Clear && delta.metadata() != metadata)
1572    }) {
1573        return Err(EncodingError::MixedMetadata { index });
1574    }
1575
1576    OrderBookDelta::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
1577}
1578
1579/// Converts a vector of `OrderBookDepth` into an Arrow `RecordBatch`.
1580///
1581/// # Errors
1582///
1583/// Returns an error if:
1584/// - `data` is empty.
1585/// - Metadata differs between rows.
1586/// - Encoding fails.
1587pub fn book_depths_to_arrow_record_batch_bytes(
1588    data: &[OrderBookDepth],
1589) -> Result<RecordBatch, EncodingError> {
1590    if data.is_empty() {
1591        return Err(EncodingError::EmptyData);
1592    }
1593    let metadata = OrderBookDepth::chunk_metadata(data);
1594
1595    if let Some(index) = data
1596        .iter()
1597        .position(|depth| !depth.matches_chunk_metadata(&metadata))
1598    {
1599        return Err(EncodingError::MixedMetadata { index });
1600    }
1601
1602    OrderBookDepth::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
1603}
1604
1605/// Converts a vector of `QuoteTick` into an Arrow `RecordBatch`.
1606///
1607/// # Errors
1608///
1609/// Returns an error if:
1610/// - `data` is empty.
1611/// - Metadata differs between rows.
1612/// - Encoding fails.
1613pub fn quotes_to_arrow_record_batch_bytes(
1614    data: &[QuoteTick],
1615) -> Result<RecordBatch, EncodingError> {
1616    encode_batch_with_metadata(data)
1617}
1618
1619/// Converts a vector of `TradeTick` into an Arrow `RecordBatch`.
1620///
1621/// # Errors
1622///
1623/// Returns an error if:
1624/// - `data` is empty.
1625/// - Metadata differs between rows.
1626/// - Encoding fails.
1627pub fn trades_to_arrow_record_batch_bytes(
1628    data: &[TradeTick],
1629) -> Result<RecordBatch, EncodingError> {
1630    encode_batch_with_metadata(data)
1631}
1632
1633/// Converts a vector of `Bar` into an Arrow `RecordBatch`.
1634///
1635/// # Errors
1636///
1637/// Returns an error if:
1638/// - `data` is empty.
1639/// - Metadata differs between rows.
1640/// - Encoding fails.
1641pub fn bars_to_arrow_record_batch_bytes(data: &[Bar]) -> Result<RecordBatch, EncodingError> {
1642    encode_batch_with_metadata(data)
1643}
1644
1645/// Converts a vector of `MarkPriceUpdate` into an Arrow `RecordBatch`.
1646///
1647/// # Errors
1648///
1649/// Returns an error if:
1650/// - `data` is empty.
1651/// - Metadata differs between rows.
1652/// - Encoding fails.
1653pub fn mark_prices_to_arrow_record_batch_bytes(
1654    data: &[MarkPriceUpdate],
1655) -> Result<RecordBatch, EncodingError> {
1656    encode_batch_with_metadata(data)
1657}
1658
1659/// Converts a vector of `IndexPriceUpdate` into an Arrow `RecordBatch`.
1660///
1661/// # Errors
1662///
1663/// Returns an error if:
1664/// - `data` is empty.
1665/// - Metadata differs between rows.
1666/// - Encoding fails.
1667pub fn index_prices_to_arrow_record_batch_bytes(
1668    data: &[IndexPriceUpdate],
1669) -> Result<RecordBatch, EncodingError> {
1670    encode_batch_with_metadata(data)
1671}
1672
1673/// Converts a vector of `InstrumentStatus` into an Arrow `RecordBatch`.
1674///
1675/// # Errors
1676///
1677/// Returns an error if:
1678/// - `data` is empty.
1679/// - Metadata differs between rows.
1680/// - Encoding fails.
1681pub fn instrument_status_to_arrow_record_batch_bytes(
1682    data: &[InstrumentStatus],
1683) -> Result<RecordBatch, EncodingError> {
1684    encode_batch_with_metadata(data)
1685}
1686
1687/// Converts a vector of `OptionGreeks` into an Arrow `RecordBatch`.
1688///
1689/// # Errors
1690///
1691/// Returns an error if:
1692/// - `data` is empty.
1693/// - Metadata differs between rows.
1694/// - Encoding fails.
1695pub fn option_greeks_to_arrow_record_batch_bytes(
1696    data: &[OptionGreeks],
1697) -> Result<RecordBatch, EncodingError> {
1698    encode_batch_with_metadata(data)
1699}
1700
1701/// Converts a vector of `InstrumentClose` into an Arrow `RecordBatch`.
1702///
1703/// # Errors
1704///
1705/// Returns an error if:
1706/// - `data` is empty.
1707/// - Metadata differs between rows.
1708/// - Encoding fails.
1709pub fn instrument_closes_to_arrow_record_batch_bytes(
1710    data: &[InstrumentClose],
1711) -> Result<RecordBatch, EncodingError> {
1712    encode_batch_with_metadata(data)
1713}
1714
1715pub(crate) fn encode_batch_with_metadata<T>(data: &[T]) -> Result<RecordBatch, EncodingError>
1716where
1717    T: EncodeToRecordBatch,
1718{
1719    if data.is_empty() {
1720        return Err(EncodingError::EmptyData);
1721    }
1722
1723    let metadata = T::chunk_metadata(data);
1724    if let Some(index) = data.iter().position(|value| value.metadata() != metadata) {
1725        return Err(EncodingError::MixedMetadata { index });
1726    }
1727
1728    T::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733    use nautilus_model::{
1734        data::{
1735            Bar, BarSpecification, BarType, BookOrder, DEPTH10_LEN, OrderBookDelta, OrderBookDepth,
1736            QuoteTick, order::NULL_ORDER,
1737        },
1738        enums::{
1739            AggregationSource, BarAggregation, BookAction, MarketStatusAction, OrderSide, PriceType,
1740        },
1741        identifiers::InstrumentId,
1742        types::{Price, Quantity},
1743    };
1744    use rstest::rstest;
1745
1746    use super::*;
1747
1748    #[cfg(feature = "high-precision")]
1749    #[rstest]
1750    fn test_money_rejects_currency_precision_above_catalog_scale() {
1751        use nautilus_model::enums::CurrencyType;
1752
1753        if nautilus_model::types::fixed::check_fixed_precision(18).is_err() {
1754            return;
1755        }
1756
1757        let currency = Currency::new("TST18", 18, 0, "Test token", CurrencyType::Crypto);
1758        let value = Money::from_raw(1_000_000_000_000_000_000, currency);
1759        let error = money_array([Some(value)]).unwrap_err();
1760
1761        assert_eq!(
1762            error.to_string(),
1763            "Invalid argument error: Money currency precision 18 exceeds catalog scale 16"
1764        );
1765    }
1766
1767    #[rstest]
1768    fn test_encode_rejects_defi_precision_metadata() {
1769        let mut metadata = QuoteTick::get_metadata(
1770            &InstrumentId::from("WETH-USDC.UNISWAP"),
1771            FIXED_DECIMAL_SCALE as u8,
1772            0,
1773        );
1774        metadata.insert(KEY_PRICE_PRECISION.to_string(), "17".to_string());
1775        let schema = Arc::new(QuoteTick::get_schema(Some(metadata)));
1776
1777        let err = record_batch_with_timestamps(schema, vec![]).unwrap_err();
1778
1779        assert_eq!(
1780            err.to_string(),
1781            "Invalid argument error: Metadata 'price_precision' is 17, maximum supported catalog scale is 16"
1782        );
1783    }
1784
1785    #[rstest]
1786    fn test_timestamp_arrays_preserve_utc_nanoseconds_and_nulls() {
1787        let values = [Some(1_788_652_800_123_456_789), None, Some(i64::MAX as u64)];
1788        let array = optional_timestamp_array(values).unwrap();
1789
1790        assert_eq!(
1791            timestamp_data_type(),
1792            DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
1793        );
1794        assert_eq!(array.data_type(), &timestamp_data_type());
1795        assert_eq!(
1796            array.iter().collect::<Vec<_>>(),
1797            vec![Some(1_788_652_800_123_456_789), None, Some(i64::MAX)]
1798        );
1799        assert_eq!(
1800            decode_timestamp(&array, "ts_event", 0).unwrap(),
1801            1_788_652_800_123_456_789
1802        );
1803        assert_eq!(
1804            decode_timestamp(&array, "ts_event", 2).unwrap(),
1805            i64::MAX as u64
1806        );
1807        assert!(
1808            decode_timestamp(&TimestampNanosecondArray::from(vec![-1]), "ts_event", 0).is_err()
1809        );
1810    }
1811
1812    #[rstest]
1813    fn test_timestamp_array_rejects_unsigned_overflow() {
1814        let error = timestamp_array([u64::MAX]).unwrap_err();
1815
1816        assert_eq!(
1817            error.to_string(),
1818            "Invalid argument error: Nanosecond timestamp 18446744073709551615 exceeds Arrow's signed timestamp range",
1819        );
1820    }
1821
1822    #[rstest]
1823    fn test_quotes_to_arrow_record_batch_rejects_mixed_instruments() {
1824        let first = QuoteTick::new(
1825            InstrumentId::from("AAPL.XNAS"),
1826            Price::from("100.01"),
1827            Price::from("100.02"),
1828            Quantity::from("10"),
1829            Quantity::from("11"),
1830            1.into(),
1831            1.into(),
1832        );
1833        let second = QuoteTick::new(
1834            InstrumentId::from("MSFT.XNAS"),
1835            Price::from("200.01"),
1836            Price::from("200.02"),
1837            Quantity::from("20"),
1838            Quantity::from("21"),
1839            2.into(),
1840            2.into(),
1841        );
1842
1843        let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
1844
1845        assert!(matches!(
1846            result,
1847            Err(EncodingError::MixedMetadata { index: 1 })
1848        ));
1849    }
1850
1851    #[rstest]
1852    fn test_instrument_status_to_arrow_record_batch_rejects_mixed_instruments() {
1853        let status = |instrument_id: &str, ts: u64| {
1854            InstrumentStatus::new(
1855                InstrumentId::from(instrument_id),
1856                MarketStatusAction::Trading,
1857                ts.into(),
1858                ts.into(),
1859                None,
1860                None,
1861                None,
1862                None,
1863                None,
1864            )
1865        };
1866
1867        let result = instrument_status_to_arrow_record_batch_bytes(&[
1868            status("AAA.XNAS", 1),
1869            status("BBB.XNAS", 2),
1870        ]);
1871
1872        assert!(matches!(
1873            result,
1874            Err(EncodingError::MixedMetadata { index: 1 })
1875        ));
1876    }
1877
1878    #[rstest]
1879    fn test_option_greeks_to_arrow_record_batch_rejects_mixed_instruments() {
1880        let greeks = |instrument_id: &str| OptionGreeks {
1881            instrument_id: InstrumentId::from(instrument_id),
1882            ..OptionGreeks::default()
1883        };
1884
1885        let result = option_greeks_to_arrow_record_batch_bytes(&[
1886            greeks("BTC-20260529-100000-C.OKX"),
1887            greeks("BTC-20260529-110000-C.OKX"),
1888        ]);
1889
1890        assert!(matches!(
1891            result,
1892            Err(EncodingError::MixedMetadata { index: 1 })
1893        ));
1894    }
1895
1896    #[rstest]
1897    fn test_quotes_to_arrow_record_batch_rejects_mixed_precision() {
1898        let instrument_id = InstrumentId::from("AAPL.XNAS");
1899        let first = QuoteTick::new(
1900            instrument_id,
1901            Price::from("100.01"),
1902            Price::from("100.02"),
1903            Quantity::from("10.00"),
1904            Quantity::from("11.00"),
1905            1.into(),
1906            1.into(),
1907        );
1908        let second = QuoteTick::new(
1909            instrument_id,
1910            Price::from("100.010"),
1911            Price::from("100.020"),
1912            Quantity::from("10.000"),
1913            Quantity::from("11.000"),
1914            2.into(),
1915            2.into(),
1916        );
1917
1918        let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
1919
1920        assert!(matches!(
1921            result,
1922            Err(EncodingError::MixedMetadata { index: 1 })
1923        ));
1924    }
1925
1926    #[rstest]
1927    fn test_bars_to_arrow_record_batch_rejects_mixed_bar_types() {
1928        let instrument_id = InstrumentId::from("AAPL.XNAS");
1929        let first_type = BarType::new(
1930            instrument_id,
1931            BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1932            AggregationSource::Internal,
1933        );
1934        let second_type = BarType::new(
1935            instrument_id,
1936            BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
1937            AggregationSource::Internal,
1938        );
1939        let first = Bar::new(
1940            first_type,
1941            Price::from("100.01"),
1942            Price::from("100.02"),
1943            Price::from("100.00"),
1944            Price::from("100.01"),
1945            Quantity::from("10"),
1946            1.into(),
1947            1.into(),
1948        );
1949        let second = Bar::new(
1950            second_type,
1951            Price::from("100.01"),
1952            Price::from("100.02"),
1953            Price::from("100.00"),
1954            Price::from("100.01"),
1955            Quantity::from("11"),
1956            2.into(),
1957            2.into(),
1958        );
1959
1960        let result = bars_to_arrow_record_batch_bytes(&[first, second]);
1961
1962        assert!(matches!(
1963            result,
1964            Err(EncodingError::MixedMetadata { index: 1 })
1965        ));
1966    }
1967
1968    #[rstest]
1969    fn test_depths_to_arrow_record_batch_rejects_mixed_level_price_precision() {
1970        let instrument_id = InstrumentId::from("AUD/USD.SIM");
1971        let bid = BookOrder::new(
1972            OrderSide::Buy,
1973            Price::from("1.23"),
1974            Quantity::from("100.00"),
1975            1,
1976        );
1977        let ask = BookOrder::new(
1978            OrderSide::Sell,
1979            Price::from("1.24"),
1980            Quantity::from("100.00"),
1981            2,
1982        );
1983        let mut asks = [ask; DEPTH10_LEN];
1984        asks[1].price = Price::from("1.241");
1985        let depth = OrderBookDepth::new(
1986            instrument_id,
1987            [bid; DEPTH10_LEN],
1988            asks,
1989            [1; DEPTH10_LEN],
1990            [1; DEPTH10_LEN],
1991            0,
1992            1,
1993            1.into(),
1994            1.into(),
1995        );
1996
1997        let result = book_depths_to_arrow_record_batch_bytes(&[depth]);
1998
1999        assert!(matches!(
2000            result,
2001            Err(EncodingError::MixedMetadata { index: 0 })
2002        ));
2003    }
2004
2005    #[rstest]
2006    fn test_depths_to_arrow_record_batch_rejects_mixed_level_size_precision() {
2007        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2008        let bid = BookOrder::new(
2009            OrderSide::Buy,
2010            Price::from("1.23"),
2011            Quantity::from("100.00"),
2012            1,
2013        );
2014        let ask = BookOrder::new(
2015            OrderSide::Sell,
2016            Price::from("1.24"),
2017            Quantity::from("100.00"),
2018            2,
2019        );
2020        let mut bids = [bid; DEPTH10_LEN];
2021        bids[1].size = Quantity::from("100.000");
2022        let depth = OrderBookDepth::new(
2023            instrument_id,
2024            bids,
2025            [ask; DEPTH10_LEN],
2026            [1; DEPTH10_LEN],
2027            [1; DEPTH10_LEN],
2028            0,
2029            1,
2030            1.into(),
2031            1.into(),
2032        );
2033
2034        let result = book_depths_to_arrow_record_batch_bytes(&[depth]);
2035
2036        assert!(matches!(
2037            result,
2038            Err(EncodingError::MixedMetadata { index: 0 })
2039        ));
2040    }
2041
2042    #[rstest]
2043    fn test_depths_to_arrow_record_batch_uses_first_defined_level_precision() {
2044        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2045        let bid = BookOrder::new(
2046            OrderSide::Buy,
2047            Price::from("1.23"),
2048            Quantity::from("100.00"),
2049            1,
2050        );
2051        let ask = BookOrder::new(
2052            OrderSide::Sell,
2053            Price::from("1.24"),
2054            Quantity::from("100.00"),
2055            2,
2056        );
2057        let mut bids = [bid; DEPTH10_LEN];
2058        bids[0] = NULL_ORDER;
2059        let depth = OrderBookDepth::new(
2060            instrument_id,
2061            bids,
2062            [ask; DEPTH10_LEN],
2063            [0; DEPTH10_LEN],
2064            [1; DEPTH10_LEN],
2065            0,
2066            1,
2067            1.into(),
2068            1.into(),
2069        );
2070
2071        let result = book_depths_to_arrow_record_batch_bytes(&[depth]).unwrap();
2072
2073        assert_eq!(
2074            result.schema().metadata().get(KEY_PRICE_PRECISION).unwrap(),
2075            "2"
2076        );
2077        assert_eq!(
2078            result.schema().metadata().get(KEY_SIZE_PRECISION).unwrap(),
2079            "2"
2080        );
2081    }
2082
2083    #[rstest]
2084    fn test_deltas_to_arrow_record_batch_skips_leading_clears_for_precision() {
2085        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2086        let first = OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into());
2087        let second = OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into());
2088        let third = OrderBookDelta::new(
2089            instrument_id,
2090            BookAction::Add,
2091            BookOrder::new(
2092                OrderSide::Buy,
2093                Price::from("1.23"),
2094                Quantity::from("100.000000"),
2095                1,
2096            ),
2097            0,
2098            2,
2099            3.into(),
2100            3.into(),
2101        );
2102        let expected = vec![first, second, third];
2103
2104        let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
2105        let metadata = batch.schema().metadata().clone();
2106        assert_eq!(
2107            metadata.get(KEY_PRICE_PRECISION).map(String::as_str),
2108            Some("2")
2109        );
2110        assert_eq!(
2111            metadata.get(KEY_SIZE_PRECISION).map(String::as_str),
2112            Some("6")
2113        );
2114
2115        let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
2116
2117        assert_eq!(decoded, expected);
2118        assert_eq!(decoded[2].order.price.precision, 2);
2119        assert_eq!(decoded[2].order.size.precision, 6);
2120    }
2121
2122    #[rstest]
2123    fn test_deltas_to_arrow_record_batch_all_clear_roundtrip() {
2124        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2125        let expected = vec![
2126            OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into()),
2127            OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into()),
2128        ];
2129
2130        let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
2131        let metadata = batch.schema().metadata().clone();
2132        let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
2133
2134        assert_eq!(decoded, expected);
2135    }
2136
2137    #[rstest]
2138    fn test_deltas_to_arrow_record_batch_rejects_mixed_precision() {
2139        let instrument_id = InstrumentId::from("AUD/USD.SIM");
2140        let first = OrderBookDelta::new(
2141            instrument_id,
2142            BookAction::Add,
2143            BookOrder::new(
2144                OrderSide::Buy,
2145                Price::from("1.23"),
2146                Quantity::from("100.00"),
2147                1,
2148            ),
2149            0,
2150            1,
2151            1.into(),
2152            1.into(),
2153        );
2154        let second = OrderBookDelta::new(
2155            instrument_id,
2156            BookAction::Update,
2157            BookOrder::new(
2158                OrderSide::Buy,
2159                Price::from("1.234"),
2160                Quantity::from("100.000"),
2161                1,
2162            ),
2163            0,
2164            2,
2165            2.into(),
2166            2.into(),
2167        );
2168
2169        let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
2170
2171        assert!(matches!(
2172            result,
2173            Err(EncodingError::MixedMetadata { index: 1 })
2174        ));
2175    }
2176
2177    #[rstest]
2178    fn test_deltas_to_arrow_record_batch_rejects_mixed_instruments() {
2179        let first = OrderBookDelta::clear(InstrumentId::from("AUD/USD.SIM"), 0, 1.into(), 1.into());
2180        let second = OrderBookDelta::new(
2181            InstrumentId::from("EUR/USD.SIM"),
2182            BookAction::Add,
2183            BookOrder::new(
2184                OrderSide::Buy,
2185                Price::from("1.23"),
2186                Quantity::from("100.00"),
2187                1,
2188            ),
2189            0,
2190            1,
2191            2.into(),
2192            2.into(),
2193        );
2194
2195        let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
2196
2197        // The first non-clear delta supplies metadata, so the leading clear is the mismatched row.
2198        assert!(matches!(
2199            result,
2200            Err(EncodingError::MixedMetadata { index: 0 })
2201        ));
2202    }
2203}
2204
2205#[cfg(test)]
2206mod schema_invariant_tests {
2207    use arrow::{
2208        array::{Array, Decimal128Array},
2209        datatypes::{DataType, Field, Schema},
2210    };
2211    use nautilus_model::{
2212        data::{
2213            BookOrder, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate,
2214            OptionGreeks,
2215            bar::Bar,
2216            close::InstrumentClose,
2217            delta::OrderBookDelta,
2218            depth::OrderBookDepth,
2219            quote::QuoteTick,
2220            stubs::{stub_bar, stub_depth10},
2221            trade::TradeTick,
2222        },
2223        enums::{AggressorSide, BookAction, InstrumentCloseType, OrderSide},
2224        events::{
2225            AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
2226            OrderEmulated, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
2227            OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected,
2228            OrderReleased, OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated,
2229            PositionAdjusted, PositionChanged, PositionClosed, PositionOpened, PositionSnapshot,
2230        },
2231        identifiers::{InstrumentId, TradeId},
2232        instruments::{
2233            InstrumentAny, betting::BettingInstrument, binary_option::BinaryOption, cfd::Cfd,
2234            commodity::Commodity, crypto_future::CryptoFuture,
2235            crypto_futures_spread::CryptoFuturesSpread, crypto_option::CryptoOption,
2236            crypto_option_spread::CryptoOptionSpread, crypto_perpetual::CryptoPerpetual,
2237            currency_pair::CurrencyPair, equity::Equity, futures_contract::FuturesContract,
2238            futures_spread::FuturesSpread, index_instrument::IndexInstrument,
2239            option_contract::OptionContract, option_spread::OptionSpread,
2240            perpetual_contract::PerpetualContract, tokenized_asset::TokenizedAsset,
2241        },
2242        reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
2243        types::{PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity},
2244    };
2245    use rstest::rstest;
2246    use rust_decimal::Decimal;
2247
2248    use super::{
2249        ArrowSchemaProvider, DecodeFromRecordBatch, EncodeToRecordBatch, FIXED_DECIMAL_PRECISION,
2250        FIXED_DECIMAL_SCALE, KEY_IDENTIFIER, QUANTITY_RAW_MAX, decimal_to_arrow, decode_decimal,
2251        decode_decimal_price, fixed_decimal_data_type, is_timestamp_field, price_decimal_array,
2252        quantity_decimal_array, timestamp_data_type,
2253    };
2254
2255    #[derive(Clone, Copy, Debug)]
2256    enum FixedFamily {
2257        Quote,
2258        Trade,
2259        Bar,
2260        Delta,
2261        Depth,
2262        MarkPrice,
2263        IndexPrice,
2264        Close,
2265    }
2266
2267    #[rstest]
2268    fn decimal_to_arrow_rejects_values_outside_decode_range() {
2269        let value = Decimal::from_i128_with_scale(8_000_000_000_000, 0);
2270
2271        let error = decimal_to_arrow(&value, "amount").unwrap_err();
2272
2273        assert!(error.to_string().contains("96-bit range"));
2274    }
2275
2276    #[rstest]
2277    fn decimal_to_arrow_normalizes_trailing_zero_scale() {
2278        let value = Decimal::from_i128_with_scale(10_000_000_000_000_000, 18);
2279        let encoded = decimal_to_arrow(&value, "amount").unwrap();
2280        let array = Decimal128Array::from(vec![encoded])
2281            .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
2282            .unwrap();
2283
2284        assert_eq!(
2285            decode_decimal(&array, "amount", 0).unwrap(),
2286            value.normalize()
2287        );
2288    }
2289
2290    macro_rules! collect_data_schemas {
2291        ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
2292            vec![
2293                $(
2294                    (stringify!($type), <$type as ArrowSchemaProvider>::get_schema(None)),
2295                )+
2296            ]
2297        };
2298    }
2299
2300    macro_rules! assert_model_field_map {
2301        // InstrumentAny is an enum over concrete instrument types and has no field map.
2302        (InstrumentAny) => {};
2303        // InstrumentStatus has no model get_fields implementation.
2304        (InstrumentStatus) => {};
2305        // OptionGreeks has no model get_fields implementation.
2306        (OptionGreeks) => {};
2307        (OrderBookDelta) => {
2308            assert_fields_match_schema(
2309                catalog_field_map(OrderBookDelta::get_fields()),
2310                &OrderBookDelta::get_schema(None),
2311                &["price", "size", KEY_IDENTIFIER],
2312            );
2313        };
2314        (OrderBookDepth) => {
2315            assert_fields_match_schema(
2316                catalog_field_map(OrderBookDepth::get_fields()),
2317                &OrderBookDepth::get_schema(None),
2318                &[KEY_IDENTIFIER],
2319            );
2320        };
2321        (QuoteTick) => {
2322            assert_fields_match_schema(
2323                catalog_field_map(QuoteTick::get_fields()),
2324                &QuoteTick::get_schema(None),
2325                &[KEY_IDENTIFIER],
2326            );
2327        };
2328        (TradeTick) => {
2329            assert_fields_match_schema(
2330                catalog_field_map(TradeTick::get_fields()),
2331                &TradeTick::get_schema(None),
2332                &[KEY_IDENTIFIER],
2333            );
2334        };
2335        (Bar) => {
2336            assert_fields_match_schema(
2337                catalog_field_map(Bar::get_fields()),
2338                &Bar::get_schema(None),
2339                &[KEY_IDENTIFIER],
2340            );
2341        };
2342        (MarkPriceUpdate) => {
2343            assert_fields_match_schema(
2344                catalog_field_map(MarkPriceUpdate::get_fields()),
2345                &MarkPriceUpdate::get_schema(None),
2346                &["value", KEY_IDENTIFIER],
2347            );
2348        };
2349        (IndexPriceUpdate) => {
2350            assert_fields_match_schema(
2351                catalog_field_map(IndexPriceUpdate::get_fields()),
2352                &IndexPriceUpdate::get_schema(None),
2353                &["value", KEY_IDENTIFIER],
2354            );
2355        };
2356        (FundingRateUpdate) => {
2357            assert_fields_match_schema(
2358                catalog_field_map(FundingRateUpdate::get_fields()),
2359                &FundingRateUpdate::get_schema(None),
2360                &["interval", "next_funding_ns", KEY_IDENTIFIER],
2361            );
2362        };
2363        (InstrumentClose) => {
2364            assert_fields_match_schema(
2365                catalog_field_map(InstrumentClose::get_fields()),
2366                &InstrumentClose::get_schema(None),
2367                &["close_price", KEY_IDENTIFIER],
2368            );
2369        };
2370    }
2371
2372    macro_rules! assert_registered_model_field_maps {
2373        ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
2374            $(assert_model_field_map!($type);)+
2375        };
2376    }
2377
2378    #[rstest]
2379    fn registered_write_schemas_have_no_opaque_byte_fields() {
2380        let mut schemas = nautilus_model::for_each_data_type!(collect_data_schemas);
2381        schemas.extend(instrument_schemas());
2382        schemas.extend(record_schemas());
2383
2384        for (name, schema) in schemas {
2385            assert_open_schema(name, &schema);
2386        }
2387    }
2388
2389    #[rstest]
2390    fn model_field_maps_match_encoder_schemas() {
2391        nautilus_model::for_each_data_type!(assert_registered_model_field_maps);
2392    }
2393
2394    #[rstest]
2395    fn fixed_point_storage_uses_uniform_scale() {
2396        let price = Price::from("1.23456789");
2397        let array = price_decimal_array([price.raw()], "price").unwrap();
2398
2399        assert_eq!(array.data_type(), &fixed_decimal_data_type());
2400        assert_eq!(
2401            decode_decimal_price(&array, price.precision, "price", 0).unwrap(),
2402            price,
2403        );
2404    }
2405
2406    #[rstest]
2407    fn undefined_fixed_point_values_encode_as_null() {
2408        let prices = price_decimal_array([PRICE_UNDEF], "price").unwrap();
2409        let quantities = quantity_decimal_array([QUANTITY_UNDEF], "quantity").unwrap();
2410
2411        assert!(prices.is_null(0));
2412        assert!(quantities.is_null(0));
2413    }
2414
2415    #[rstest]
2416    fn price_error_fails_with_field_and_value() {
2417        let error = price_decimal_array([PRICE_ERROR], "bid_price").unwrap_err();
2418
2419        assert_eq!(
2420            error.to_string(),
2421            format!(
2422                "Invalid argument error: Price field 'bid_price' contains PRICE_ERROR raw value {PRICE_ERROR}"
2423            ),
2424        );
2425    }
2426
2427    #[rstest]
2428    fn quantity_overflow_fails_with_field_and_value() {
2429        let raw = QUANTITY_RAW_MAX + 1;
2430        let error = quantity_decimal_array([raw], "bid_size").unwrap_err();
2431
2432        assert_eq!(
2433            error.to_string(),
2434            format!(
2435                "Invalid argument error: Quantity field 'bid_size' raw value {raw} exceeds QUANTITY_RAW_MAX={QUANTITY_RAW_MAX}"
2436            ),
2437        );
2438    }
2439
2440    #[cfg(not(feature = "high-precision"))]
2441    #[rstest]
2442    fn standard_precision_decode_rejects_nonzero_scale_remainder() {
2443        let array = Decimal128Array::from(vec![1_i128])
2444            .with_precision_and_scale(38, 16)
2445            .unwrap();
2446
2447        let error = decode_decimal_price(&array, 9, "bid_price", 0).unwrap_err();
2448
2449        assert_eq!(
2450            error.to_string(),
2451            "Error parsing `bid_price`: row 0: decimal value 1 has nonzero digits beyond build \
2452             precision 9",
2453        );
2454    }
2455
2456    #[cfg(feature = "high-precision")]
2457    #[rstest]
2458    fn high_precision_decimal_payload_is_bit_exact() {
2459        let raw = 1_234_567_890_123_456_i128;
2460        let array = price_decimal_array([raw], "price").unwrap();
2461
2462        assert_eq!(array.value(0), raw);
2463        assert_eq!(
2464            decode_decimal_price(&array, 16, "price", 0).unwrap().raw(),
2465            raw
2466        );
2467    }
2468
2469    #[rstest]
2470    #[case::quote(FixedFamily::Quote)]
2471    #[case::trade(FixedFamily::Trade)]
2472    #[case::bar(FixedFamily::Bar)]
2473    #[case::delta(FixedFamily::Delta)]
2474    #[case::depth(FixedFamily::Depth)]
2475    #[case::mark_price(FixedFamily::MarkPrice)]
2476    #[case::index_price(FixedFamily::IndexPrice)]
2477    #[case::close(FixedFamily::Close)]
2478    fn fixed_family_round_trips_multiple_precisions(#[case] family: FixedFamily) {
2479        round_trip_fixed_family(family, Price::from("1"), Quantity::from("2"));
2480        round_trip_fixed_family(family, Price::from("1.23456"), Quantity::from("2.34567"));
2481        #[cfg(feature = "high-precision")]
2482        round_trip_fixed_family(
2483            family,
2484            Price::from("1.2345678901234567"),
2485            Quantity::from("2.3456789012345678"),
2486        );
2487    }
2488
2489    #[rstest]
2490    #[case::quote(FixedFamily::Quote)]
2491    #[case::trade(FixedFamily::Trade)]
2492    #[case::bar(FixedFamily::Bar)]
2493    #[case::delta(FixedFamily::Delta)]
2494    #[case::mark_price(FixedFamily::MarkPrice)]
2495    #[case::index_price(FixedFamily::IndexPrice)]
2496    #[case::close(FixedFamily::Close)]
2497    fn fixed_family_sentinel_encoding(#[case] family: FixedFamily) {
2498        let instrument_id = InstrumentId::from("SENTINEL.TEST");
2499        let price = Price::from_raw(PRICE_UNDEF, 0);
2500        let quantity = Quantity::from_raw(QUANTITY_UNDEF, 0);
2501
2502        match family {
2503            FixedFamily::Quote => {
2504                let value = QuoteTick::new(
2505                    instrument_id,
2506                    price,
2507                    Price::from("1"),
2508                    quantity,
2509                    Quantity::from("1"),
2510                    1.into(),
2511                    2.into(),
2512                );
2513                let metadata = QuoteTick::get_metadata(&instrument_id, 0, 0);
2514                let error = QuoteTick::encode_batch(&metadata, &[value]).unwrap_err();
2515                assert!(error.to_string().contains("bid_price"));
2516                assert!(error.to_string().contains("PRICE_UNDEF"));
2517            }
2518            FixedFamily::Trade => {
2519                let value = TradeTick {
2520                    instrument_id,
2521                    price,
2522                    size: quantity,
2523                    aggressor_side: AggressorSide::Buy,
2524                    trade_id: TradeId::from("sentinel"),
2525                    ts_event: 1.into(),
2526                    ts_init: 2.into(),
2527                };
2528                let metadata = TradeTick::get_metadata(&instrument_id, 0, 0);
2529                let error = TradeTick::encode_batch(&metadata, &[value]).unwrap_err();
2530                assert!(error.to_string().contains("price"));
2531                assert!(error.to_string().contains("PRICE_UNDEF"));
2532            }
2533            FixedFamily::Bar => {
2534                let mut value = stub_bar();
2535                value.open = price;
2536                value.volume = quantity;
2537                let metadata = Bar::get_metadata(&value.bar_type, 0, 0);
2538                let error = Bar::encode_batch(&metadata, &[value]).unwrap_err();
2539                assert!(error.to_string().contains("open"));
2540                assert!(error.to_string().contains("PRICE_UNDEF"));
2541            }
2542            FixedFamily::Delta => {
2543                let value = OrderBookDelta {
2544                    instrument_id,
2545                    action: BookAction::Update,
2546                    order: BookOrder {
2547                        side: OrderSide::Buy.into(),
2548                        price,
2549                        size: quantity,
2550                        order_id: 1,
2551                    },
2552                    flags: 0,
2553                    sequence: 1,
2554                    ts_event: 1.into(),
2555                    ts_init: 2.into(),
2556                };
2557                let metadata = OrderBookDelta::get_metadata(&instrument_id, 0, 0);
2558                let batch = OrderBookDelta::encode_batch(&metadata, &[value]).unwrap();
2559                assert!(batch.column_by_name("price").unwrap().is_null(0));
2560                assert!(batch.column_by_name("size").unwrap().is_null(0));
2561                assert_eq!(
2562                    OrderBookDelta::decode_batch(&metadata, batch).unwrap(),
2563                    vec![value],
2564                );
2565            }
2566            FixedFamily::Depth => unreachable!("depth sides omit absent levels"),
2567            FixedFamily::MarkPrice => {
2568                let value = MarkPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2569                let metadata = MarkPriceUpdate::get_metadata(&instrument_id, 0);
2570                let batch = MarkPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2571                assert!(batch.column_by_name("value").unwrap().is_null(0));
2572                assert_eq!(
2573                    MarkPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2574                    vec![value],
2575                );
2576            }
2577            FixedFamily::IndexPrice => {
2578                let value = IndexPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2579                let metadata = IndexPriceUpdate::get_metadata(&instrument_id, 0);
2580                let batch = IndexPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2581                assert!(batch.column_by_name("value").unwrap().is_null(0));
2582                assert_eq!(
2583                    IndexPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2584                    vec![value],
2585                );
2586            }
2587            FixedFamily::Close => {
2588                let value = InstrumentClose::new(
2589                    instrument_id,
2590                    price,
2591                    InstrumentCloseType::EndOfSession,
2592                    1.into(),
2593                    2.into(),
2594                );
2595                let metadata = InstrumentClose::get_metadata(&instrument_id, 0);
2596                let batch = InstrumentClose::encode_batch(&metadata, &[value]).unwrap();
2597                assert!(batch.column_by_name("close_price").unwrap().is_null(0));
2598                assert_eq!(
2599                    InstrumentClose::decode_batch(&metadata, batch).unwrap(),
2600                    vec![value],
2601                );
2602            }
2603        }
2604    }
2605
2606    fn round_trip_fixed_family(family: FixedFamily, price: Price, quantity: Quantity) {
2607        let instrument_id = InstrumentId::from("PRECISION.TEST");
2608
2609        match family {
2610            FixedFamily::Quote => {
2611                let value = QuoteTick::new(
2612                    instrument_id,
2613                    price,
2614                    price,
2615                    quantity,
2616                    quantity,
2617                    1.into(),
2618                    2.into(),
2619                );
2620                let metadata =
2621                    QuoteTick::get_metadata(&instrument_id, price.precision, quantity.precision);
2622                let batch = QuoteTick::encode_batch(&metadata, &[value]).unwrap();
2623                assert_eq!(
2624                    QuoteTick::decode_batch(&metadata, batch).unwrap(),
2625                    vec![value],
2626                );
2627            }
2628            FixedFamily::Trade => {
2629                let value = TradeTick::new(
2630                    instrument_id,
2631                    price,
2632                    quantity,
2633                    AggressorSide::Buy,
2634                    TradeId::from("precision"),
2635                    1.into(),
2636                    2.into(),
2637                );
2638                let metadata =
2639                    TradeTick::get_metadata(&instrument_id, price.precision, quantity.precision);
2640                let batch = TradeTick::encode_batch(&metadata, &[value]).unwrap();
2641                assert_eq!(
2642                    TradeTick::decode_batch(&metadata, batch).unwrap(),
2643                    vec![value],
2644                );
2645            }
2646            FixedFamily::Bar => {
2647                let mut value = stub_bar();
2648                value.open = price;
2649                value.high = price;
2650                value.low = price;
2651                value.close = price;
2652                value.volume = quantity;
2653                let metadata =
2654                    Bar::get_metadata(&value.bar_type, price.precision, quantity.precision);
2655                let batch = Bar::encode_batch(&metadata, &[value]).unwrap();
2656                assert_eq!(Bar::decode_batch(&metadata, batch).unwrap(), vec![value]);
2657            }
2658            FixedFamily::Delta => {
2659                let value = OrderBookDelta::new(
2660                    instrument_id,
2661                    BookAction::Add,
2662                    BookOrder {
2663                        side: OrderSide::Buy.into(),
2664                        price,
2665                        size: quantity,
2666                        order_id: 1,
2667                    },
2668                    0,
2669                    1,
2670                    1.into(),
2671                    2.into(),
2672                );
2673                let metadata = OrderBookDelta::get_metadata(
2674                    &instrument_id,
2675                    price.precision,
2676                    quantity.precision,
2677                );
2678                let batch = OrderBookDelta::encode_batch(&metadata, &[value]).unwrap();
2679                assert_eq!(
2680                    OrderBookDelta::decode_batch(&metadata, batch).unwrap(),
2681                    vec![value],
2682                );
2683            }
2684            FixedFamily::Depth => {
2685                let mut value = stub_depth10();
2686                for order in value.bids.iter_mut().chain(value.asks.iter_mut()) {
2687                    order.price = price;
2688                    order.size = quantity;
2689                }
2690                let metadata = OrderBookDepth::get_metadata(
2691                    &value.instrument_id,
2692                    price.precision,
2693                    quantity.precision,
2694                );
2695                let batch = OrderBookDepth::encode_batch(&metadata, &[value.clone()]).unwrap();
2696                assert_eq!(
2697                    OrderBookDepth::decode_batch(&metadata, batch).unwrap(),
2698                    vec![value],
2699                );
2700            }
2701            FixedFamily::MarkPrice => {
2702                let value = MarkPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2703                let metadata = MarkPriceUpdate::get_metadata(&instrument_id, price.precision);
2704                let batch = MarkPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2705                assert_eq!(
2706                    MarkPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2707                    vec![value],
2708                );
2709            }
2710            FixedFamily::IndexPrice => {
2711                let value = IndexPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2712                let metadata = IndexPriceUpdate::get_metadata(&instrument_id, price.precision);
2713                let batch = IndexPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2714                assert_eq!(
2715                    IndexPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2716                    vec![value],
2717                );
2718            }
2719            FixedFamily::Close => {
2720                let value = InstrumentClose::new(
2721                    instrument_id,
2722                    price,
2723                    InstrumentCloseType::EndOfSession,
2724                    1.into(),
2725                    2.into(),
2726                );
2727                let metadata = InstrumentClose::get_metadata(&instrument_id, price.precision);
2728                let batch = InstrumentClose::encode_batch(&metadata, &[value]).unwrap();
2729                assert_eq!(
2730                    InstrumentClose::decode_batch(&metadata, batch).unwrap(),
2731                    vec![value],
2732                );
2733            }
2734        }
2735    }
2736
2737    fn instrument_schemas() -> Vec<(&'static str, Schema)> {
2738        // Keep this list explicit until instrument types have a registry equivalent to data types.
2739        vec![
2740            schema::<BettingInstrument>(),
2741            schema::<BinaryOption>(),
2742            schema::<Cfd>(),
2743            schema::<Commodity>(),
2744            schema::<CryptoFuture>(),
2745            schema::<CryptoFuturesSpread>(),
2746            schema::<CryptoOption>(),
2747            schema::<CryptoOptionSpread>(),
2748            schema::<CryptoPerpetual>(),
2749            schema::<CurrencyPair>(),
2750            schema::<Equity>(),
2751            schema::<FuturesContract>(),
2752            schema::<FuturesSpread>(),
2753            schema::<IndexInstrument>(),
2754            schema::<OptionContract>(),
2755            schema::<OptionSpread>(),
2756            schema::<PerpetualContract>(),
2757            schema::<TokenizedAsset>(),
2758        ]
2759    }
2760
2761    fn record_schemas() -> Vec<(&'static str, Schema)> {
2762        // Keep this list explicit until record types have a registry equivalent to data types.
2763        vec![
2764            schema::<AccountState>(),
2765            schema::<OrderInitialized>(),
2766            schema::<OrderDenied>(),
2767            schema::<OrderEmulated>(),
2768            schema::<OrderSubmitted>(),
2769            schema::<OrderAccepted>(),
2770            schema::<OrderRejected>(),
2771            schema::<OrderPendingCancel>(),
2772            schema::<OrderCanceled>(),
2773            schema::<OrderCancelRejected>(),
2774            schema::<OrderExpired>(),
2775            schema::<OrderTriggered>(),
2776            schema::<OrderPendingUpdate>(),
2777            schema::<OrderReleased>(),
2778            schema::<OrderModifyRejected>(),
2779            schema::<OrderUpdated>(),
2780            schema::<OrderFilled>(),
2781            schema::<OrderFillVoided>(),
2782            schema::<PositionOpened>(),
2783            schema::<PositionChanged>(),
2784            schema::<PositionClosed>(),
2785            schema::<PositionAdjusted>(),
2786            schema::<OrderStatusReport>(),
2787            schema::<FillReport>(),
2788            schema::<PositionStatusReport>(),
2789            schema::<ExecutionMassStatus>(),
2790            schema::<OrderSnapshot>(),
2791            schema::<PositionSnapshot>(),
2792        ]
2793    }
2794
2795    fn schema<T: ArrowSchemaProvider>() -> (&'static str, Schema) {
2796        (std::any::type_name::<T>(), T::get_schema(None))
2797    }
2798
2799    fn catalog_field_map(
2800        fields: impl IntoIterator<Item = (String, String)>,
2801    ) -> Vec<(String, String)> {
2802        let mut fields = fields.into_iter().collect::<Vec<_>>();
2803        fields.push((KEY_IDENTIFIER.to_string(), "Utf8".to_string()));
2804        fields
2805    }
2806
2807    fn assert_fields_match_schema(
2808        expected: Vec<(String, String)>,
2809        schema: &Schema,
2810        nullable: &[&str],
2811    ) {
2812        let expected = expected
2813            .into_iter()
2814            .map(|(name, data_type)| {
2815                let is_nullable = nullable.contains(&name.as_str());
2816                (name, data_type, is_nullable)
2817            })
2818            .collect::<Vec<_>>();
2819        let actual: Vec<_> = schema
2820            .fields()
2821            .iter()
2822            .map(|field| {
2823                (
2824                    field.name().clone(),
2825                    arrow_type_name(field.data_type()),
2826                    field.is_nullable(),
2827                )
2828            })
2829            .collect();
2830
2831        assert_eq!(actual, expected);
2832    }
2833
2834    fn arrow_type_name(data_type: &DataType) -> String {
2835        match data_type {
2836            DataType::List(field) => format!("List({})", arrow_type_name(field.data_type())),
2837            DataType::Struct(fields) => {
2838                let fields = fields
2839                    .iter()
2840                    .map(|field| {
2841                        format!("{}: {}", field.name(), arrow_type_name(field.data_type()))
2842                    })
2843                    .collect::<Vec<_>>()
2844                    .join(", ");
2845                format!("Struct({fields})")
2846            }
2847            _ => format!("{data_type:?}"),
2848        }
2849    }
2850
2851    fn assert_open_schema(name: &str, schema: &Schema) {
2852        for field in schema.fields() {
2853            assert_open_field(name, field);
2854        }
2855    }
2856
2857    fn assert_open_field(schema_name: &str, field: &Field) {
2858        if is_timestamp_field(field.name()) {
2859            assert_eq!(
2860                field.data_type(),
2861                &timestamp_data_type(),
2862                "write schema `{schema_name}` timestamp field `{}` is not a UTC nanosecond timestamp",
2863                field.name(),
2864            );
2865        }
2866
2867        match field.data_type() {
2868            DataType::Binary
2869            | DataType::LargeBinary
2870            | DataType::BinaryView
2871            | DataType::FixedSizeBinary(_) => {
2872                panic!(
2873                    "write schema `{schema_name}` contains opaque byte field `{}`: {}",
2874                    field.name(),
2875                    field.data_type(),
2876                );
2877            }
2878            DataType::List(child)
2879            | DataType::LargeList(child)
2880            | DataType::ListView(child)
2881            | DataType::LargeListView(child)
2882            | DataType::FixedSizeList(child, _)
2883            | DataType::Map(child, _) => assert_open_field(schema_name, child),
2884            DataType::Struct(children) => {
2885                for child in children {
2886                    assert_open_field(schema_name, child);
2887                }
2888            }
2889            _ => {}
2890        }
2891    }
2892}