1pub 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
103pub 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#[must_use]
137pub fn timestamp_data_type() -> DataType {
138 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
139}
140
141pub fn timestamp_array(
147 values: impl IntoIterator<Item = u64>,
148) -> Result<TimestampNanosecondArray, ArrowError> {
149 optional_timestamp_array(values.into_iter().map(Some))
150}
151
152pub 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
177pub 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
198pub 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() != ×tamp_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
236fn 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
265pub 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() != ×tamp_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#[must_use]
321pub fn enum_dictionary_data_type() -> DataType {
322 DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8))
323}
324
325pub 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#[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
356pub 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
398pub 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
429pub 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
449pub 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#[must_use]
572pub const fn fixed_decimal_data_type() -> DataType {
573 DataType::Decimal128(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
574}
575
576#[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#[must_use]
587pub fn is_json_string_field(field: &Field) -> bool {
588 field.extension_type_name() == Some("arrow.json")
589}
590
591pub 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
622pub 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
659pub 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
676pub 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
693pub 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
717pub 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
785pub 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
806pub 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
826pub 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
847pub 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
867pub 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#[inline]
999fn get_corrected_raw_price(bytes: &[u8], precision: u8) -> PriceRaw {
1000 let raw = get_raw_price(bytes);
1001
1002 if raw == PRICE_UNDEF || raw == PRICE_ERROR {
1004 return raw;
1005 }
1006
1007 correct_price_raw(raw, precision)
1008}
1009
1010#[inline]
1018fn get_corrected_raw_quantity(bytes: &[u8], precision: u8) -> QuantityRaw {
1019 let raw = get_raw_quantity(bytes);
1020
1021 if raw == QUANTITY_UNDEF {
1023 return raw;
1024 }
1025
1026 correct_quantity_raw(raw, precision)
1027}
1028
1029pub 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
1048pub 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
1067pub 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
1090pub 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
1113pub trait ArrowSchemaProvider {
1115 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema;
1117
1118 #[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
1133pub trait EncodeToRecordBatch
1135where
1136 Self: Sized + ArrowSchemaProvider,
1137{
1138 fn encode_batch<T>(
1144 metadata: &HashMap<String, String>,
1145 data: &[T],
1146 ) -> Result<RecordBatch, ArrowError>
1147 where
1148 T: Borrow<Self>;
1149
1150 fn metadata(&self) -> HashMap<String, String>;
1152
1153 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 fn matches_chunk_metadata(&self, metadata: &HashMap<String, String>) -> bool {
1173 self.metadata() == *metadata
1174 }
1175}
1176
1177#[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#[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#[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
1219pub 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
1235pub 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#[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
1281pub 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
1297pub trait DecodeFromRecordBatch
1299where
1300 Self: Sized + Into<Data> + ArrowSchemaProvider,
1301{
1302 fn decode_batch(
1308 metadata: &HashMap<String, String>,
1309 record_batch: RecordBatch,
1310 ) -> Result<Vec<Self>, EncodingError>;
1311}
1312
1313pub trait DecodeTypedFromRecordBatch
1315where
1316 Self: Sized + ArrowSchemaProvider,
1317{
1318 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
1341pub trait DecodeDataFromRecordBatch
1343where
1344 Self: Sized + ArrowSchemaProvider,
1345{
1346 fn decode_data_batch(
1352 metadata: &HashMap<String, String>,
1353 record_batch: RecordBatch,
1354 ) -> Result<Vec<Data>, EncodingError>;
1355}
1356
1357pub trait WriteStream {
1359 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
1376pub 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#[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 #[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 #[must_use]
1425 pub fn is_empty(&self) -> bool {
1426 self.len() == 0
1427 }
1428
1429 #[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 #[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 #[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 #[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#[derive(Debug)]
1498pub enum U64ColumnRef<'a> {
1499 UInt64(&'a UInt64Array),
1500 Int64(&'a Int64Array),
1501 TimestampNanosecond(&'a TimestampNanosecondArray),
1502}
1503
1504impl U64ColumnRef<'_> {
1505 #[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 #[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 #[must_use]
1534 pub fn is_empty(&self) -> bool {
1535 self.len() == 0
1536 }
1537
1538 #[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 #[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#[derive(Debug)]
1561pub enum U32ColumnRef<'a> {
1562 UInt32(&'a UInt32Array),
1563 Int32(&'a Int32Array),
1564 Int64(&'a Int64Array),
1565}
1566
1567impl U32ColumnRef<'_> {
1568 #[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
1586pub 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#[derive(Debug)]
1620pub enum BinaryColumnRef<'a> {
1621 Binary(&'a BinaryArray),
1622 BinaryView(&'a BinaryViewArray),
1623}
1624
1625impl BinaryColumnRef<'_> {
1626 #[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 #[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
1647pub 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
1677pub 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
1700pub 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
1718pub 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#[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
1751pub 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
1775pub 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
1808pub 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
1834pub fn quotes_to_arrow_record_batch_bytes(
1843 data: &[QuoteTick],
1844) -> Result<RecordBatch, EncodingError> {
1845 encode_batch_with_metadata(data)
1846}
1847
1848pub fn trades_to_arrow_record_batch_bytes(
1857 data: &[TradeTick],
1858) -> Result<RecordBatch, EncodingError> {
1859 encode_batch_with_metadata(data)
1860}
1861
1862pub fn bars_to_arrow_record_batch_bytes(data: &[Bar]) -> Result<RecordBatch, EncodingError> {
1871 encode_batch_with_metadata(data)
1872}
1873
1874pub fn mark_prices_to_arrow_record_batch_bytes(
1883 data: &[MarkPriceUpdate],
1884) -> Result<RecordBatch, EncodingError> {
1885 encode_batch_with_metadata(data)
1886}
1887
1888pub fn index_prices_to_arrow_record_batch_bytes(
1897 data: &[IndexPriceUpdate],
1898) -> Result<RecordBatch, EncodingError> {
1899 encode_batch_with_metadata(data)
1900}
1901
1902#[expect(clippy::missing_panics_doc)] pub 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#[expect(clippy::missing_panics_doc)] pub 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
1942pub 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(), ×tamp_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 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) => {};
2497 (InstrumentStatus) => {};
2499 (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 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 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 ×tamp_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}