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