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 greeks;
27pub mod index_price;
28pub mod instrument;
29pub mod instrument_status;
30pub mod json;
31pub mod legacy;
32pub mod mark_price;
33pub mod option_greeks;
34pub mod order_event;
35pub mod position_event;
36pub mod quote;
37pub mod report;
38pub mod snapshot;
39pub mod trade;
40
41#[cfg(feature = "arrow-display")]
42pub mod display;
43
44mod depth_display;
45mod display_conversion;
46
47#[cfg(test)]
48pub(crate) mod test_support;
49
50use std::{
51 borrow::Borrow,
52 collections::HashMap,
53 fmt::{Display, Write as FmtWrite},
54 io::{self, Write},
55 str::FromStr,
56 sync::Arc,
57};
58
59use arrow::{
60 array::{
61 Array, ArrayRef, BinaryArray, BinaryViewArray, Decimal128Array, DictionaryArray,
62 Int32Array, Int64Array, StringArray, StringBuilder, StringDictionaryBuilder,
63 StringViewArray, StructArray, TimestampNanosecondArray, UInt8Array, UInt32Array,
64 UInt64Array,
65 },
66 buffer::NullBuffer,
67 datatypes::{DataType, Field, Int8Type, Int32Type, Schema, TimeUnit},
68 error::ArrowError,
69 ipc::writer::StreamWriter,
70 record_batch::RecordBatch,
71};
72use nautilus_core::UnixNanos;
73use nautilus_model::{
74 data::{
75 Data, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, bar::Bar,
76 close::InstrumentClose, delta::OrderBookDelta, depth::OrderBookDepth,
77 option_chain::OptionGreeks, quote::QuoteTick, trade::TradeTick,
78 },
79 enums::BookAction,
80 identifiers::InstrumentId,
81 types::{
82 Currency, Money, PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity,
83 fixed::{FIXED_PRECISION, FIXED_PRECISION_STANDARD},
84 money::MoneyRaw,
85 price::PriceRaw,
86 quantity::{QUANTITY_RAW_MAX, QuantityRaw},
87 },
88};
89#[cfg(feature = "python")]
90use pyo3::prelude::*;
91use rust_decimal::Decimal;
92use ustr::Ustr;
93
94pub use self::legacy::{
95 is_nautilus_legacy_schema, is_nautilus_timestamp_schema, is_timestamp_field,
96 normalize_legacy_fixed_columns, normalized_legacy_data_type, normalized_timestamp_type,
97};
98
99pub const KEY_BAR_TYPE: &str = "bar_type";
101pub const KEY_IDENTIFIER: &str = "identifier";
102pub const KEY_INSTRUMENT_ID: &str = "instrument_id";
103pub const KEY_PRICE_PRECISION: &str = "price_precision";
104pub const KEY_SIZE_PRECISION: &str = "size_precision";
105
106pub(crate) fn parse_metadata(
107 metadata: &HashMap<String, String>,
108) -> Result<(InstrumentId, u8, u8), EncodingError> {
109 let instrument_id = metadata
110 .get(KEY_INSTRUMENT_ID)
111 .ok_or(EncodingError::MissingMetadata(KEY_INSTRUMENT_ID))?
112 .parse::<InstrumentId>()
113 .map_err(|e| EncodingError::ParseError(KEY_INSTRUMENT_ID, e.to_string()))?;
114 let price_precision = metadata
115 .get(KEY_PRICE_PRECISION)
116 .ok_or(EncodingError::MissingMetadata(KEY_PRICE_PRECISION))?
117 .parse::<u8>()
118 .map_err(|e| EncodingError::ParseError(KEY_PRICE_PRECISION, e.to_string()))?;
119 let size_precision = metadata
120 .get(KEY_SIZE_PRECISION)
121 .ok_or(EncodingError::MissingMetadata(KEY_SIZE_PRECISION))?
122 .parse::<u8>()
123 .map_err(|e| EncodingError::ParseError(KEY_SIZE_PRECISION, e.to_string()))?;
124 Ok((instrument_id, price_precision, size_precision))
125}
126pub const KEY_TYPE_NAME: &str = "type_name";
127pub const FIXED_DECIMAL_PRECISION: u8 = 38;
128pub const FIXED_DECIMAL_SCALE: i8 = 16;
129pub(crate) const EMPTY_DEPTH_PRECISION: (u8, u8) = (0, 0);
130
131#[must_use]
133pub fn metadata_with_type_name(
134 type_name: &str,
135 metadata: Option<HashMap<String, String>>,
136) -> HashMap<String, String> {
137 let mut metadata = metadata.unwrap_or_default();
138 metadata.insert(KEY_TYPE_NAME.to_string(), type_name.to_string());
139 metadata
140}
141
142#[must_use]
144pub fn timestamp_data_type() -> DataType {
145 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
146}
147
148pub fn timestamp_array(
154 values: impl IntoIterator<Item = u64>,
155) -> Result<TimestampNanosecondArray, ArrowError> {
156 optional_timestamp_array(values.into_iter().map(Some))
157}
158
159pub fn optional_timestamp_array(
165 values: impl IntoIterator<Item = Option<u64>>,
166) -> Result<TimestampNanosecondArray, ArrowError> {
167 let values = values
168 .into_iter()
169 .map(|value| {
170 value
171 .map(|value| {
172 i64::try_from(value).map_err(|_| {
173 ArrowError::InvalidArgumentError(format!(
174 "Nanosecond timestamp {value} exceeds Arrow's signed timestamp range"
175 ))
176 })
177 })
178 .transpose()
179 })
180 .collect::<Result<Vec<_>, _>>()?;
181 Ok(TimestampNanosecondArray::from(values).with_data_type(timestamp_data_type()))
182}
183
184pub fn decode_timestamp(
190 values: &TimestampNanosecondArray,
191 name: &'static str,
192 row: usize,
193) -> Result<u64, EncodingError> {
194 u64::try_from(values.value(row)).map_err(|_| {
195 EncodingError::ParseError(
196 name,
197 format!(
198 "row {row}: negative nanosecond timestamp {}",
199 values.value(row)
200 ),
201 )
202 })
203}
204
205pub fn record_batch_with_timestamps(
215 schema: Arc<Schema>,
216 columns: Vec<ArrayRef>,
217) -> Result<RecordBatch, ArrowError> {
218 validate_encode_precisions(schema.metadata())?;
219
220 let columns = schema
221 .fields()
222 .iter()
223 .zip(columns)
224 .map(|(field, column)| {
225 if field.data_type() != ×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(Arc::clone(field));
278 columns.push(Arc::clone(column));
279 continue;
280 }
281 let values = column
282 .as_any()
283 .downcast_ref::<TimestampNanosecondArray>()
284 .ok_or_else(|| {
285 EncodingError::ParseError(
286 "timestamp",
287 format!("Column '{}' is not TimestampNanosecond", field.name()),
288 )
289 })?;
290 let values = (0..values.len())
291 .map(|row| {
292 if values.is_null(row) {
293 Ok(None)
294 } else {
295 decode_timestamp(values, "timestamp", row).map(Some)
296 }
297 })
298 .collect::<Result<Vec<_>, _>>()?;
299 fields.push(Arc::new(
300 field.as_ref().clone().with_data_type(DataType::UInt64),
301 ));
302 columns.push(Arc::new(UInt64Array::from(values)) as ArrayRef);
303 changed = true;
304 }
305
306 if !changed {
307 return Ok(batch.clone());
308 }
309 RecordBatch::try_new(
310 Arc::new(Schema::new_with_metadata(
311 fields,
312 batch.schema().metadata().clone(),
313 )),
314 columns,
315 )
316 .map_err(EncodingError::from)
317}
318
319#[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("Arrow error: {0}")]
556 ArrowError(#[from] arrow::error::ArrowError),
557}
558
559#[must_use]
561pub const fn fixed_decimal_data_type() -> DataType {
562 DataType::Decimal128(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
563}
564
565#[must_use]
567pub fn json_string_field(name: impl Into<String>, nullable: bool) -> Field {
568 Field::new(name, DataType::Utf8, nullable).with_metadata(HashMap::from([
569 ("ARROW:extension:name".to_string(), "arrow.json".to_string()),
570 ("ARROW:extension:metadata".to_string(), String::new()),
571 ]))
572}
573
574#[must_use]
576pub fn is_json_string_field(field: &Field) -> bool {
577 field.extension_type_name() == Some("arrow.json")
578}
579
580pub fn price_raw_to_decimal(
586 raw: PriceRaw,
587 field: &'static str,
588) -> Result<Option<i128>, ArrowError> {
589 if raw == PRICE_UNDEF {
590 return Ok(None);
591 }
592
593 if raw == PRICE_ERROR {
594 return Err(ArrowError::InvalidArgumentError(format!(
595 "Price field '{field}' contains PRICE_ERROR raw value {raw}"
596 )));
597 }
598
599 #[allow(
600 clippy::useless_conversion,
601 reason = "PriceRaw is i64 or i128 depending on model feature unification"
602 )]
603 let mut decimal = i128::from(raw);
604 if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
605 decimal *= STANDARD_TO_DECIMAL_SCALE;
606 }
607
608 Ok(Some(decimal))
609}
610
611pub fn quantity_raw_to_decimal(
618 raw: QuantityRaw,
619 field: &'static str,
620) -> Result<Option<i128>, ArrowError> {
621 if raw == QUANTITY_UNDEF {
622 return Ok(None);
623 }
624
625 if raw > QUANTITY_RAW_MAX {
626 return Err(ArrowError::InvalidArgumentError(format!(
627 "Quantity field '{field}' raw value {raw} exceeds QUANTITY_RAW_MAX={QUANTITY_RAW_MAX}"
628 )));
629 }
630
631 #[allow(
632 clippy::unnecessary_fallible_conversions,
633 reason = "QuantityRaw is u64 or u128 depending on model feature unification"
634 )]
635 let mut decimal = i128::try_from(raw).map_err(|_| {
636 ArrowError::InvalidArgumentError(format!(
637 "Quantity field '{field}' raw value {raw} exceeds Decimal128 range"
638 ))
639 })?;
640
641 if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
642 decimal *= STANDARD_TO_DECIMAL_SCALE;
643 }
644
645 Ok(Some(decimal))
646}
647
648pub fn price_decimal_array(
654 values: impl IntoIterator<Item = PriceRaw>,
655 field: &'static str,
656) -> Result<Decimal128Array, ArrowError> {
657 let values = values
658 .into_iter()
659 .map(|raw| price_raw_to_decimal(raw, field))
660 .collect::<Result<Vec<_>, _>>()?;
661 Decimal128Array::from(values)
662 .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
663}
664
665pub fn quantity_decimal_array(
671 values: impl IntoIterator<Item = QuantityRaw>,
672 field: &'static str,
673) -> Result<Decimal128Array, ArrowError> {
674 let values = values
675 .into_iter()
676 .map(|raw| quantity_raw_to_decimal(raw, field))
677 .collect::<Result<Vec<_>, _>>()?;
678 Decimal128Array::from(values)
679 .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
680}
681
682pub fn required_price_decimal_array(
689 values: impl IntoIterator<Item = PriceRaw>,
690 field: &'static str,
691) -> Result<Decimal128Array, ArrowError> {
692 let values = values
693 .into_iter()
694 .map(|raw| {
695 price_raw_to_decimal(raw, field)?.ok_or_else(|| {
696 ArrowError::InvalidArgumentError(format!(
697 "Price field '{field}' contains PRICE_UNDEF, which has no sentinel encoding for this type"
698 ))
699 })
700 })
701 .collect::<Result<Vec<_>, _>>()?;
702 Decimal128Array::from(values)
703 .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
704}
705
706pub fn required_quantity_decimal_array(
713 values: impl IntoIterator<Item = QuantityRaw>,
714 field: &'static str,
715) -> Result<Decimal128Array, ArrowError> {
716 let values = values
717 .into_iter()
718 .map(|raw| {
719 quantity_raw_to_decimal(raw, field)?.ok_or_else(|| {
720 ArrowError::InvalidArgumentError(format!(
721 "Quantity field '{field}' contains QUANTITY_UNDEF, which has no sentinel encoding for this type"
722 ))
723 })
724 })
725 .collect::<Result<Vec<_>, _>>()?;
726 Decimal128Array::from(values)
727 .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
728}
729
730fn decimal_to_price_raw(
731 value: i128,
732 field: &'static str,
733 row: usize,
734) -> Result<PriceRaw, EncodingError> {
735 decimal_to_raw(value, field, row, "PriceRaw")
736}
737
738fn decimal_to_quantity_raw(
739 value: i128,
740 field: &'static str,
741 row: usize,
742) -> Result<QuantityRaw, EncodingError> {
743 decimal_to_raw(value, field, row, "QuantityRaw")
744}
745
746fn decimal_to_raw<T: TryFrom<i128>>(
747 value: i128,
748 field: &'static str,
749 row: usize,
750 raw_type: &'static str,
751) -> Result<T, EncodingError> {
752 let raw_value = if FIXED_PRECISION == FIXED_PRECISION_STANDARD {
753 if value % STANDARD_TO_DECIMAL_SCALE != 0 {
754 return Err(EncodingError::ParseError(
755 field,
756 format!(
757 "row {row}: decimal value {value} has nonzero digits beyond build precision 9"
758 ),
759 ));
760 }
761 value / STANDARD_TO_DECIMAL_SCALE
762 } else {
763 value
764 };
765
766 T::try_from(raw_value).map_err(|_| {
767 EncodingError::ParseError(
768 field,
769 format!("row {row}: decimal value {value} exceeds {raw_type} range"),
770 )
771 })
772}
773
774pub fn decode_decimal_price(
780 values: &Decimal128Array,
781 precision: u8,
782 field: &'static str,
783 row: usize,
784) -> Result<Price, EncodingError> {
785 if values.is_null(row) {
786 return Price::from_raw_checked(PRICE_UNDEF, 0)
787 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")));
788 }
789
790 let raw = decimal_to_price_raw(values.value(row), field, row)?;
791 Price::from_raw_checked(raw, precision)
792 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
793}
794
795pub fn decode_required_decimal_price(
801 values: &Decimal128Array,
802 precision: u8,
803 field: &'static str,
804 row: usize,
805) -> Result<Price, EncodingError> {
806 if values.is_null(row) {
807 return Err(EncodingError::ParseError(
808 field,
809 format!("row {row}: required price is null"),
810 ));
811 }
812 decode_decimal_price(values, precision, field, row)
813}
814
815pub fn decode_decimal_quantity(
821 values: &Decimal128Array,
822 precision: u8,
823 field: &'static str,
824 row: usize,
825) -> Result<Quantity, EncodingError> {
826 if values.is_null(row) {
827 return Quantity::from_raw_checked(QUANTITY_UNDEF, 0)
828 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")));
829 }
830
831 let raw = decimal_to_quantity_raw(values.value(row), field, row)?;
832 Quantity::from_raw_checked(raw, precision)
833 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
834}
835
836pub fn decode_required_decimal_quantity(
842 values: &Decimal128Array,
843 precision: u8,
844 field: &'static str,
845 row: usize,
846) -> Result<Quantity, EncodingError> {
847 if values.is_null(row) {
848 return Err(EncodingError::ParseError(
849 field,
850 format!("row {row}: required quantity is null"),
851 ));
852 }
853 decode_decimal_quantity(values, precision, field, row)
854}
855
856pub fn decode_required_timestamp(
862 values: &UInt64Array,
863 field: &'static str,
864 row: usize,
865) -> Result<UnixNanos, EncodingError> {
866 if values.is_null(row) {
867 return Err(EncodingError::ParseError(
868 field,
869 format!("row {row}: required timestamp is null"),
870 ));
871 }
872 Ok(values.value(row).into())
873}
874
875pub(crate) fn decode_required_u64(
876 values: &UInt64Array,
877 field: &'static str,
878 row: usize,
879) -> Result<u64, EncodingError> {
880 if values.is_null(row) {
881 return Err(EncodingError::ParseError(
882 field,
883 format!("row {row}: required integer is null"),
884 ));
885 }
886 Ok(values.value(row))
887}
888
889pub(crate) fn decode_required_u8(
890 values: &UInt8Array,
891 field: &'static str,
892 row: usize,
893) -> Result<u8, EncodingError> {
894 if values.is_null(row) {
895 return Err(EncodingError::ParseError(
896 field,
897 format!("row {row}: required integer is null"),
898 ));
899 }
900 Ok(values.value(row))
901}
902
903#[cfg(test)]
904fn get_raw_price(value: i128) -> PriceRaw {
905 decimal_to_price_raw(value, "test", 0).expect("Decimal price must fit the current build")
906}
907
908#[cfg(test)]
909fn get_raw_quantity(value: i128) -> QuantityRaw {
910 decimal_to_quantity_raw(value, "test", 0).expect("Decimal quantity must fit the current build")
911}
912
913pub trait ArrowSchemaProvider {
915 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema;
917
918 #[must_use]
920 fn get_schema_map() -> HashMap<String, String> {
921 let schema = Self::get_schema(None);
922 let mut map = HashMap::new();
923
924 for field in schema.fields() {
925 let name = field.name().clone();
926 let data_type = format!("{:?}", field.data_type());
927 map.insert(name, data_type);
928 }
929 map
930 }
931}
932
933pub trait EncodeToRecordBatch
935where
936 Self: Sized + ArrowSchemaProvider,
937{
938 fn encode_batch<T>(
944 metadata: &HashMap<String, String>,
945 data: &[T],
946 ) -> Result<RecordBatch, ArrowError>
947 where
948 T: Borrow<Self>;
949
950 fn metadata(&self) -> HashMap<String, String>;
952
953 fn chunk_metadata<T>(chunk: &[T]) -> HashMap<String, String>
962 where
963 T: Borrow<Self>,
964 {
965 chunk
966 .first()
967 .map(|item| item.borrow().metadata())
968 .expect("Chunk must contain at least one element to encode")
969 }
970
971 fn matches_chunk_metadata(&self, metadata: &HashMap<String, String>) -> bool {
973 self.metadata() == *metadata
974 }
975}
976
977#[must_use]
982pub fn catalog_identifier_from_metadata(metadata: &HashMap<String, String>) -> Option<String> {
983 metadata
984 .get(KEY_BAR_TYPE)
985 .cloned()
986 .or_else(|| metadata.get(KEY_INSTRUMENT_ID).cloned())
987}
988
989#[must_use]
991pub fn schema_with_identifier_column(schema: &Schema) -> Schema {
992 if schema.index_of(KEY_IDENTIFIER).is_ok() {
993 return schema.clone();
994 }
995
996 let mut fields = schema.fields().iter().cloned().collect::<Vec<_>>();
997 fields.push(Arc::new(Field::new(KEY_IDENTIFIER, DataType::Utf8, true)));
998
999 Schema::new_with_metadata(fields, schema.metadata().clone())
1000}
1001
1002#[must_use]
1004pub fn schema_without_identifier_column(schema: &Schema) -> Schema {
1005 let Ok(identifier_index) = schema.index_of(KEY_IDENTIFIER) else {
1006 return schema.clone();
1007 };
1008
1009 let fields = schema
1010 .fields()
1011 .iter()
1012 .enumerate()
1013 .filter_map(|(index, field)| (index != identifier_index).then_some(Arc::clone(field)))
1014 .collect::<Vec<_>>();
1015
1016 Schema::new_with_metadata(fields, schema.metadata().clone())
1017}
1018
1019pub fn record_batch_with_identifier_column(
1028 batch: RecordBatch,
1029 identifier: Option<&str>,
1030) -> Result<RecordBatch, ArrowError> {
1031 let identifier_values = vec![identifier.map(ToString::to_string); batch.num_rows()];
1032 record_batch_with_identifier_values(batch, identifier_values)
1033}
1034
1035pub fn record_batch_with_identifier_values(
1042 batch: RecordBatch,
1043 identifier_values: Vec<Option<String>>,
1044) -> Result<RecordBatch, ArrowError> {
1045 if batch.schema().index_of(KEY_IDENTIFIER).is_ok() {
1046 return Ok(batch);
1047 }
1048
1049 if identifier_values.len() != batch.num_rows() {
1050 return Err(ArrowError::InvalidArgumentError(format!(
1051 "identifier values length {} does not match record batch row count {}",
1052 identifier_values.len(),
1053 batch.num_rows()
1054 )));
1055 }
1056
1057 let schema = schema_with_identifier_column(batch.schema().as_ref());
1058 let mut columns = batch.columns().to_vec();
1059 columns.push(Arc::new(StringArray::from(identifier_values)));
1060
1061 RecordBatch::try_new(Arc::new(schema), columns)
1062}
1063
1064#[must_use]
1066pub fn identifier_array_from_display<T: Display>(
1067 identifiers: impl IntoIterator<Item = T>,
1068) -> StringArray {
1069 let mut builder = StringBuilder::new();
1070 let mut scratch = String::new();
1071
1072 for identifier in identifiers {
1073 scratch.clear();
1074 write!(&mut scratch, "{identifier}").expect("writing to String should not fail");
1075 builder.append_value(scratch.as_str());
1076 }
1077
1078 builder.finish()
1079}
1080
1081pub fn record_batch_without_identifier_column(
1087 mut batch: RecordBatch,
1088) -> Result<RecordBatch, ArrowError> {
1089 let Ok(identifier_index) = batch.schema().index_of(KEY_IDENTIFIER) else {
1090 return Ok(batch);
1091 };
1092
1093 batch.remove_column(identifier_index);
1094 Ok(batch)
1095}
1096
1097pub trait DecodeFromRecordBatch
1099where
1100 Self: Sized + Into<Data> + ArrowSchemaProvider,
1101{
1102 fn decode_batch(
1108 metadata: &HashMap<String, String>,
1109 record_batch: RecordBatch,
1110 ) -> Result<Vec<Self>, EncodingError>;
1111}
1112
1113pub trait DecodeTypedFromRecordBatch
1115where
1116 Self: Sized + ArrowSchemaProvider,
1117{
1118 fn decode_typed_batch(
1124 metadata: &HashMap<String, String>,
1125 record_batch: RecordBatch,
1126 ) -> Result<Vec<Self>, EncodingError>;
1127}
1128
1129impl<T> DecodeTypedFromRecordBatch for T
1130where
1131 T: DecodeFromRecordBatch,
1132{
1133 fn decode_typed_batch(
1134 metadata: &HashMap<String, String>,
1135 record_batch: RecordBatch,
1136 ) -> Result<Vec<Self>, EncodingError> {
1137 Self::decode_batch(metadata, record_batch)
1138 }
1139}
1140
1141pub trait DecodeDataFromRecordBatch
1143where
1144 Self: Sized + ArrowSchemaProvider,
1145{
1146 fn decode_data_batch(
1152 metadata: &HashMap<String, String>,
1153 record_batch: RecordBatch,
1154 ) -> Result<Vec<Data>, EncodingError>;
1155}
1156
1157pub trait WriteStream {
1159 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError>;
1165}
1166
1167impl<T: Write> WriteStream for T {
1168 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError> {
1169 let mut writer = StreamWriter::try_new(self, &record_batch.schema())?;
1170 writer.write(record_batch)?;
1171 writer.finish()?;
1172 Ok(())
1173 }
1174}
1175
1176pub fn extract_column_string<'a>(
1185 cols: &'a [ArrayRef],
1186 column_key: &'static str,
1187 column_index: usize,
1188) -> Result<StringColumnRef<'a>, EncodingError> {
1189 let column_values = cols
1190 .get(column_index)
1191 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1192 StringColumnRef::try_from_array(column_values.as_ref()).ok_or_else(|| {
1193 EncodingError::InvalidColumnType(
1194 column_key,
1195 column_index,
1196 DataType::Utf8,
1197 column_values.data_type().clone(),
1198 )
1199 })
1200}
1201
1202#[derive(Debug)]
1204pub enum StringColumnRef<'a> {
1205 DictionaryInt8(&'a DictionaryArray<Int8Type>, &'a StringArray),
1206 DictionaryInt32(&'a DictionaryArray<Int32Type>, &'a StringArray),
1207 Utf8(&'a StringArray),
1208 Utf8View(&'a StringViewArray),
1209}
1210
1211impl StringColumnRef<'_> {
1212 #[must_use]
1214 pub fn len(&self) -> usize {
1215 match self {
1216 Self::DictionaryInt8(array, _) => array.len(),
1217 Self::DictionaryInt32(array, _) => array.len(),
1218 Self::Utf8(array) => array.len(),
1219 Self::Utf8View(array) => array.len(),
1220 }
1221 }
1222
1223 #[must_use]
1225 pub fn is_empty(&self) -> bool {
1226 self.len() == 0
1227 }
1228
1229 #[must_use]
1231 pub fn try_from_array(array: &dyn Array) -> Option<StringColumnRef<'_>> {
1232 if let Some(array) = array.as_any().downcast_ref::<DictionaryArray<Int8Type>>()
1233 && let Some(values) = array.values().as_any().downcast_ref::<StringArray>()
1234 {
1235 return Some(StringColumnRef::DictionaryInt8(array, values));
1236 }
1237
1238 if let Some(array) = array.as_any().downcast_ref::<DictionaryArray<Int32Type>>()
1239 && let Some(values) = array.values().as_any().downcast_ref::<StringArray>()
1240 {
1241 return Some(StringColumnRef::DictionaryInt32(array, values));
1242 }
1243
1244 if let Some(array) = array.as_any().downcast_ref::<StringArray>() {
1245 return Some(StringColumnRef::Utf8(array));
1246 }
1247
1248 array
1249 .as_any()
1250 .downcast_ref::<StringViewArray>()
1251 .map(StringColumnRef::Utf8View)
1252 }
1253
1254 #[inline]
1260 #[must_use]
1261 pub fn value(&self, i: usize) -> &str {
1262 match self {
1263 Self::DictionaryInt8(array, values) => {
1264 let key = usize::try_from(array.keys().value(i)).expect("Int8 key is non-negative");
1265 values.value(key)
1266 }
1267 Self::DictionaryInt32(array, values) => {
1268 let key =
1269 usize::try_from(array.keys().value(i)).expect("Int32 key is non-negative");
1270 values.value(key)
1271 }
1272 Self::Utf8(arr) => arr.value(i),
1273 Self::Utf8View(arr) => arr.value(i),
1274 }
1275 }
1276
1277 #[inline]
1279 #[must_use]
1280 pub fn is_null(&self, i: usize) -> bool {
1281 match self {
1282 Self::DictionaryInt8(array, _) => array.is_null(i),
1283 Self::DictionaryInt32(array, _) => array.is_null(i),
1284 Self::Utf8(arr) => arr.is_null(i),
1285 Self::Utf8View(arr) => arr.is_null(i),
1286 }
1287 }
1288
1289 #[must_use]
1291 pub fn value_opt(&self, i: usize) -> Option<&str> {
1292 (!self.is_null(i)).then(|| self.value(i))
1293 }
1294}
1295
1296#[derive(Debug)]
1298pub enum U64ColumnRef<'a> {
1299 UInt64(&'a UInt64Array),
1300 Int64(&'a Int64Array),
1301 TimestampNanosecond(&'a TimestampNanosecondArray),
1302}
1303
1304impl U64ColumnRef<'_> {
1305 #[must_use]
1307 pub fn try_from_array(array: &dyn Array) -> Option<U64ColumnRef<'_>> {
1308 if let Some(array) = array.as_any().downcast_ref::<UInt64Array>() {
1309 return Some(U64ColumnRef::UInt64(array));
1310 }
1311
1312 if let Some(array) = array.as_any().downcast_ref::<Int64Array>() {
1313 return Some(U64ColumnRef::Int64(array));
1314 }
1315
1316 array
1317 .as_any()
1318 .downcast_ref::<TimestampNanosecondArray>()
1319 .map(U64ColumnRef::TimestampNanosecond)
1320 }
1321
1322 #[must_use]
1324 pub fn len(&self) -> usize {
1325 match self {
1326 Self::UInt64(array) => array.len(),
1327 Self::Int64(array) => array.len(),
1328 Self::TimestampNanosecond(array) => array.len(),
1329 }
1330 }
1331
1332 #[must_use]
1334 pub fn is_empty(&self) -> bool {
1335 self.len() == 0
1336 }
1337
1338 #[must_use]
1340 pub fn is_null(&self, i: usize) -> bool {
1341 match self {
1342 Self::UInt64(array) => array.is_null(i),
1343 Self::Int64(array) => array.is_null(i),
1344 Self::TimestampNanosecond(array) => array.is_null(i),
1345 }
1346 }
1347
1348 #[must_use]
1350 pub fn value(&self, i: usize) -> Option<u64> {
1351 match self {
1352 Self::UInt64(array) => Some(array.value(i)),
1353 Self::Int64(array) => u64::try_from(array.value(i)).ok(),
1354 Self::TimestampNanosecond(array) => u64::try_from(array.value(i)).ok(),
1355 }
1356 }
1357}
1358
1359#[derive(Debug)]
1361pub enum U32ColumnRef<'a> {
1362 UInt32(&'a UInt32Array),
1363 Int32(&'a Int32Array),
1364 Int64(&'a Int64Array),
1365}
1366
1367impl U32ColumnRef<'_> {
1368 #[must_use]
1370 pub fn try_from_array(array: &dyn Array) -> Option<U32ColumnRef<'_>> {
1371 if let Some(array) = array.as_any().downcast_ref::<UInt32Array>() {
1372 return Some(U32ColumnRef::UInt32(array));
1373 }
1374
1375 if let Some(array) = array.as_any().downcast_ref::<Int32Array>() {
1376 return Some(U32ColumnRef::Int32(array));
1377 }
1378
1379 array
1380 .as_any()
1381 .downcast_ref::<Int64Array>()
1382 .map(U32ColumnRef::Int64)
1383 }
1384}
1385
1386pub fn extract_column_binary<'a>(
1396 cols: &'a [ArrayRef],
1397 column_key: &'static str,
1398 column_index: usize,
1399) -> Result<BinaryColumnRef<'a>, EncodingError> {
1400 let column_values = cols
1401 .get(column_index)
1402 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1403 let dt = column_values.data_type();
1404 if let Some(arr) = column_values.as_any().downcast_ref::<BinaryArray>() {
1405 Ok(BinaryColumnRef::Binary(arr))
1406 } else if let Some(arr) = column_values.as_any().downcast_ref::<BinaryViewArray>() {
1407 Ok(BinaryColumnRef::BinaryView(arr))
1408 } else {
1409 Err(EncodingError::InvalidColumnType(
1410 column_key,
1411 column_index,
1412 DataType::Binary,
1413 dt.clone(),
1414 ))
1415 }
1416}
1417
1418#[derive(Debug)]
1420pub enum BinaryColumnRef<'a> {
1421 Binary(&'a BinaryArray),
1422 BinaryView(&'a BinaryViewArray),
1423}
1424
1425impl BinaryColumnRef<'_> {
1426 #[inline]
1428 #[must_use]
1429 pub fn is_null(&self, i: usize) -> bool {
1430 match self {
1431 Self::Binary(arr) => arr.is_null(i),
1432 Self::BinaryView(arr) => arr.is_null(i),
1433 }
1434 }
1435
1436 #[inline]
1438 #[must_use]
1439 pub fn value(&self, i: usize) -> &[u8] {
1440 match self {
1441 Self::Binary(arr) => arr.value(i),
1442 Self::BinaryView(arr) => arr.value(i),
1443 }
1444 }
1445}
1446
1447pub fn extract_column<'a, T: Array + 'static>(
1455 cols: &'a [ArrayRef],
1456 column_key: &'static str,
1457 column_index: usize,
1458 expected_type: DataType,
1459) -> Result<&'a T, EncodingError> {
1460 let column_values = cols
1461 .get(column_index)
1462 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1463
1464 let downcasted_values = column_values
1465 .as_any()
1466 .downcast_ref::<T>()
1467 .filter(|_| column_values.data_type() == &expected_type)
1468 .ok_or(EncodingError::InvalidColumnType(
1469 column_key,
1470 column_index,
1471 expected_type,
1472 column_values.data_type().clone(),
1473 ))?;
1474 Ok(downcasted_values)
1475}
1476
1477pub fn extract_column_by_name<'a, T: Array + 'static>(
1483 record_batch: &'a RecordBatch,
1484 column_key: &'static str,
1485 expected_type: DataType,
1486) -> Result<&'a T, EncodingError> {
1487 let column_index = record_batch.schema().index_of(column_key)?;
1488 extract_column::<T>(
1489 record_batch.columns(),
1490 column_key,
1491 column_index,
1492 expected_type,
1493 )
1494}
1495
1496pub fn extract_decimal_column<'a>(
1502 record_batch: &'a RecordBatch,
1503 column_key: &'static str,
1504) -> Result<&'a Decimal128Array, EncodingError> {
1505 let column_index = record_batch.schema().index_of(column_key)?;
1506 extract_column(
1507 record_batch.columns(),
1508 column_key,
1509 column_index,
1510 fixed_decimal_data_type(),
1511 )
1512}
1513
1514pub fn extract_optional_string_column_by_name<'a>(
1520 record_batch: &'a RecordBatch,
1521 column_key: &'static str,
1522) -> Result<Option<&'a StringArray>, EncodingError> {
1523 let Ok(column_index) = record_batch.schema().index_of(column_key) else {
1524 return Ok(None);
1525 };
1526 let column_values = record_batch
1527 .columns()
1528 .get(column_index)
1529 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
1530 let downcasted_values = column_values.as_any().downcast_ref::<StringArray>().ok_or(
1531 EncodingError::InvalidColumnType(
1532 column_key,
1533 column_index,
1534 DataType::Utf8,
1535 column_values.data_type().clone(),
1536 ),
1537 )?;
1538 Ok(Some(downcasted_values))
1539}
1540
1541#[must_use]
1543pub fn optional_ustr_value(values: Option<&StringArray>, row: usize) -> Option<Ustr> {
1544 values.and_then(|column| (!column.is_null(row)).then(|| Ustr::from(column.value(row))))
1545}
1546
1547pub fn book_deltas_to_arrow_record_batch_bytes(
1556 data: &[OrderBookDelta],
1557) -> Result<RecordBatch, EncodingError> {
1558 let Some(first) = data.first() else {
1559 return Err(EncodingError::EmptyData);
1560 };
1561
1562 let metadata = OrderBookDelta::chunk_metadata(data);
1563 let instrument_id = data
1564 .iter()
1565 .find(|delta| delta.action != BookAction::Clear)
1566 .unwrap_or(first)
1567 .instrument_id;
1568
1569 if let Some(index) = data.iter().position(|delta| {
1570 delta.instrument_id != instrument_id
1571 || (delta.action != BookAction::Clear && delta.metadata() != metadata)
1572 }) {
1573 return Err(EncodingError::MixedMetadata { index });
1574 }
1575
1576 OrderBookDelta::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
1577}
1578
1579pub fn book_depths_to_arrow_record_batch_bytes(
1588 data: &[OrderBookDepth],
1589) -> Result<RecordBatch, EncodingError> {
1590 if data.is_empty() {
1591 return Err(EncodingError::EmptyData);
1592 }
1593 let metadata = OrderBookDepth::chunk_metadata(data);
1594
1595 if let Some(index) = data
1596 .iter()
1597 .position(|depth| !depth.matches_chunk_metadata(&metadata))
1598 {
1599 return Err(EncodingError::MixedMetadata { index });
1600 }
1601
1602 OrderBookDepth::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
1603}
1604
1605pub fn quotes_to_arrow_record_batch_bytes(
1614 data: &[QuoteTick],
1615) -> Result<RecordBatch, EncodingError> {
1616 encode_batch_with_metadata(data)
1617}
1618
1619pub fn trades_to_arrow_record_batch_bytes(
1628 data: &[TradeTick],
1629) -> Result<RecordBatch, EncodingError> {
1630 encode_batch_with_metadata(data)
1631}
1632
1633pub fn bars_to_arrow_record_batch_bytes(data: &[Bar]) -> Result<RecordBatch, EncodingError> {
1642 encode_batch_with_metadata(data)
1643}
1644
1645pub fn mark_prices_to_arrow_record_batch_bytes(
1654 data: &[MarkPriceUpdate],
1655) -> Result<RecordBatch, EncodingError> {
1656 encode_batch_with_metadata(data)
1657}
1658
1659pub fn index_prices_to_arrow_record_batch_bytes(
1668 data: &[IndexPriceUpdate],
1669) -> Result<RecordBatch, EncodingError> {
1670 encode_batch_with_metadata(data)
1671}
1672
1673pub fn instrument_status_to_arrow_record_batch_bytes(
1682 data: &[InstrumentStatus],
1683) -> Result<RecordBatch, EncodingError> {
1684 encode_batch_with_metadata(data)
1685}
1686
1687pub fn option_greeks_to_arrow_record_batch_bytes(
1696 data: &[OptionGreeks],
1697) -> Result<RecordBatch, EncodingError> {
1698 encode_batch_with_metadata(data)
1699}
1700
1701pub fn instrument_closes_to_arrow_record_batch_bytes(
1710 data: &[InstrumentClose],
1711) -> Result<RecordBatch, EncodingError> {
1712 encode_batch_with_metadata(data)
1713}
1714
1715pub(crate) fn encode_batch_with_metadata<T>(data: &[T]) -> Result<RecordBatch, EncodingError>
1716where
1717 T: EncodeToRecordBatch,
1718{
1719 if data.is_empty() {
1720 return Err(EncodingError::EmptyData);
1721 }
1722
1723 let metadata = T::chunk_metadata(data);
1724 if let Some(index) = data.iter().position(|value| value.metadata() != metadata) {
1725 return Err(EncodingError::MixedMetadata { index });
1726 }
1727
1728 T::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use nautilus_model::{
1734 data::{
1735 Bar, BarSpecification, BarType, BookOrder, DEPTH10_LEN, OrderBookDelta, OrderBookDepth,
1736 QuoteTick, order::NULL_ORDER,
1737 },
1738 enums::{
1739 AggregationSource, BarAggregation, BookAction, MarketStatusAction, OrderSide, PriceType,
1740 },
1741 identifiers::InstrumentId,
1742 types::{Price, Quantity},
1743 };
1744 use rstest::rstest;
1745
1746 use super::*;
1747
1748 #[cfg(feature = "high-precision")]
1749 #[rstest]
1750 fn test_money_rejects_currency_precision_above_catalog_scale() {
1751 use nautilus_model::enums::CurrencyType;
1752
1753 if nautilus_model::types::fixed::check_fixed_precision(18).is_err() {
1754 return;
1755 }
1756
1757 let currency = Currency::new("TST18", 18, 0, "Test token", CurrencyType::Crypto);
1758 let value = Money::from_raw(1_000_000_000_000_000_000, currency);
1759 let error = money_array([Some(value)]).unwrap_err();
1760
1761 assert_eq!(
1762 error.to_string(),
1763 "Invalid argument error: Money currency precision 18 exceeds catalog scale 16"
1764 );
1765 }
1766
1767 #[rstest]
1768 fn test_encode_rejects_defi_precision_metadata() {
1769 let mut metadata = QuoteTick::get_metadata(
1770 &InstrumentId::from("WETH-USDC.UNISWAP"),
1771 FIXED_DECIMAL_SCALE as u8,
1772 0,
1773 );
1774 metadata.insert(KEY_PRICE_PRECISION.to_string(), "17".to_string());
1775 let schema = Arc::new(QuoteTick::get_schema(Some(metadata)));
1776
1777 let err = record_batch_with_timestamps(schema, vec![]).unwrap_err();
1778
1779 assert_eq!(
1780 err.to_string(),
1781 "Invalid argument error: Metadata 'price_precision' is 17, maximum supported catalog scale is 16"
1782 );
1783 }
1784
1785 #[rstest]
1786 fn test_timestamp_arrays_preserve_utc_nanoseconds_and_nulls() {
1787 let values = [Some(1_788_652_800_123_456_789), None, Some(i64::MAX as u64)];
1788 let array = optional_timestamp_array(values).unwrap();
1789
1790 assert_eq!(
1791 timestamp_data_type(),
1792 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
1793 );
1794 assert_eq!(array.data_type(), ×tamp_data_type());
1795 assert_eq!(
1796 array.iter().collect::<Vec<_>>(),
1797 vec![Some(1_788_652_800_123_456_789), None, Some(i64::MAX)]
1798 );
1799 assert_eq!(
1800 decode_timestamp(&array, "ts_event", 0).unwrap(),
1801 1_788_652_800_123_456_789
1802 );
1803 assert_eq!(
1804 decode_timestamp(&array, "ts_event", 2).unwrap(),
1805 i64::MAX as u64
1806 );
1807 assert!(
1808 decode_timestamp(&TimestampNanosecondArray::from(vec![-1]), "ts_event", 0).is_err()
1809 );
1810 }
1811
1812 #[rstest]
1813 fn test_timestamp_array_rejects_unsigned_overflow() {
1814 let error = timestamp_array([u64::MAX]).unwrap_err();
1815
1816 assert_eq!(
1817 error.to_string(),
1818 "Invalid argument error: Nanosecond timestamp 18446744073709551615 exceeds Arrow's signed timestamp range",
1819 );
1820 }
1821
1822 #[rstest]
1823 fn test_quotes_to_arrow_record_batch_rejects_mixed_instruments() {
1824 let first = QuoteTick::new(
1825 InstrumentId::from("AAPL.XNAS"),
1826 Price::from("100.01"),
1827 Price::from("100.02"),
1828 Quantity::from("10"),
1829 Quantity::from("11"),
1830 1.into(),
1831 1.into(),
1832 );
1833 let second = QuoteTick::new(
1834 InstrumentId::from("MSFT.XNAS"),
1835 Price::from("200.01"),
1836 Price::from("200.02"),
1837 Quantity::from("20"),
1838 Quantity::from("21"),
1839 2.into(),
1840 2.into(),
1841 );
1842
1843 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
1844
1845 assert!(matches!(
1846 result,
1847 Err(EncodingError::MixedMetadata { index: 1 })
1848 ));
1849 }
1850
1851 #[rstest]
1852 fn test_instrument_status_to_arrow_record_batch_rejects_mixed_instruments() {
1853 let status = |instrument_id: &str, ts: u64| {
1854 InstrumentStatus::new(
1855 InstrumentId::from(instrument_id),
1856 MarketStatusAction::Trading,
1857 ts.into(),
1858 ts.into(),
1859 None,
1860 None,
1861 None,
1862 None,
1863 None,
1864 )
1865 };
1866
1867 let result = instrument_status_to_arrow_record_batch_bytes(&[
1868 status("AAA.XNAS", 1),
1869 status("BBB.XNAS", 2),
1870 ]);
1871
1872 assert!(matches!(
1873 result,
1874 Err(EncodingError::MixedMetadata { index: 1 })
1875 ));
1876 }
1877
1878 #[rstest]
1879 fn test_option_greeks_to_arrow_record_batch_rejects_mixed_instruments() {
1880 let greeks = |instrument_id: &str| OptionGreeks {
1881 instrument_id: InstrumentId::from(instrument_id),
1882 ..OptionGreeks::default()
1883 };
1884
1885 let result = option_greeks_to_arrow_record_batch_bytes(&[
1886 greeks("BTC-20260529-100000-C.OKX"),
1887 greeks("BTC-20260529-110000-C.OKX"),
1888 ]);
1889
1890 assert!(matches!(
1891 result,
1892 Err(EncodingError::MixedMetadata { index: 1 })
1893 ));
1894 }
1895
1896 #[rstest]
1897 fn test_quotes_to_arrow_record_batch_rejects_mixed_precision() {
1898 let instrument_id = InstrumentId::from("AAPL.XNAS");
1899 let first = QuoteTick::new(
1900 instrument_id,
1901 Price::from("100.01"),
1902 Price::from("100.02"),
1903 Quantity::from("10.00"),
1904 Quantity::from("11.00"),
1905 1.into(),
1906 1.into(),
1907 );
1908 let second = QuoteTick::new(
1909 instrument_id,
1910 Price::from("100.010"),
1911 Price::from("100.020"),
1912 Quantity::from("10.000"),
1913 Quantity::from("11.000"),
1914 2.into(),
1915 2.into(),
1916 );
1917
1918 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
1919
1920 assert!(matches!(
1921 result,
1922 Err(EncodingError::MixedMetadata { index: 1 })
1923 ));
1924 }
1925
1926 #[rstest]
1927 fn test_bars_to_arrow_record_batch_rejects_mixed_bar_types() {
1928 let instrument_id = InstrumentId::from("AAPL.XNAS");
1929 let first_type = BarType::new(
1930 instrument_id,
1931 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
1932 AggregationSource::Internal,
1933 );
1934 let second_type = BarType::new(
1935 instrument_id,
1936 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
1937 AggregationSource::Internal,
1938 );
1939 let first = Bar::new(
1940 first_type,
1941 Price::from("100.01"),
1942 Price::from("100.02"),
1943 Price::from("100.00"),
1944 Price::from("100.01"),
1945 Quantity::from("10"),
1946 1.into(),
1947 1.into(),
1948 );
1949 let second = Bar::new(
1950 second_type,
1951 Price::from("100.01"),
1952 Price::from("100.02"),
1953 Price::from("100.00"),
1954 Price::from("100.01"),
1955 Quantity::from("11"),
1956 2.into(),
1957 2.into(),
1958 );
1959
1960 let result = bars_to_arrow_record_batch_bytes(&[first, second]);
1961
1962 assert!(matches!(
1963 result,
1964 Err(EncodingError::MixedMetadata { index: 1 })
1965 ));
1966 }
1967
1968 #[rstest]
1969 fn test_depths_to_arrow_record_batch_rejects_mixed_level_price_precision() {
1970 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1971 let bid = BookOrder::new(
1972 OrderSide::Buy,
1973 Price::from("1.23"),
1974 Quantity::from("100.00"),
1975 1,
1976 );
1977 let ask = BookOrder::new(
1978 OrderSide::Sell,
1979 Price::from("1.24"),
1980 Quantity::from("100.00"),
1981 2,
1982 );
1983 let mut asks = [ask; DEPTH10_LEN];
1984 asks[1].price = Price::from("1.241");
1985 let depth = OrderBookDepth::new(
1986 instrument_id,
1987 [bid; DEPTH10_LEN],
1988 asks,
1989 [1; DEPTH10_LEN],
1990 [1; DEPTH10_LEN],
1991 0,
1992 1,
1993 1.into(),
1994 1.into(),
1995 );
1996
1997 let result = book_depths_to_arrow_record_batch_bytes(&[depth]);
1998
1999 assert!(matches!(
2000 result,
2001 Err(EncodingError::MixedMetadata { index: 0 })
2002 ));
2003 }
2004
2005 #[rstest]
2006 fn test_depths_to_arrow_record_batch_rejects_mixed_level_size_precision() {
2007 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2008 let bid = BookOrder::new(
2009 OrderSide::Buy,
2010 Price::from("1.23"),
2011 Quantity::from("100.00"),
2012 1,
2013 );
2014 let ask = BookOrder::new(
2015 OrderSide::Sell,
2016 Price::from("1.24"),
2017 Quantity::from("100.00"),
2018 2,
2019 );
2020 let mut bids = [bid; DEPTH10_LEN];
2021 bids[1].size = Quantity::from("100.000");
2022 let depth = OrderBookDepth::new(
2023 instrument_id,
2024 bids,
2025 [ask; DEPTH10_LEN],
2026 [1; DEPTH10_LEN],
2027 [1; DEPTH10_LEN],
2028 0,
2029 1,
2030 1.into(),
2031 1.into(),
2032 );
2033
2034 let result = book_depths_to_arrow_record_batch_bytes(&[depth]);
2035
2036 assert!(matches!(
2037 result,
2038 Err(EncodingError::MixedMetadata { index: 0 })
2039 ));
2040 }
2041
2042 #[rstest]
2043 fn test_depths_to_arrow_record_batch_uses_first_defined_level_precision() {
2044 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2045 let bid = BookOrder::new(
2046 OrderSide::Buy,
2047 Price::from("1.23"),
2048 Quantity::from("100.00"),
2049 1,
2050 );
2051 let ask = BookOrder::new(
2052 OrderSide::Sell,
2053 Price::from("1.24"),
2054 Quantity::from("100.00"),
2055 2,
2056 );
2057 let mut bids = [bid; DEPTH10_LEN];
2058 bids[0] = NULL_ORDER;
2059 let depth = OrderBookDepth::new(
2060 instrument_id,
2061 bids,
2062 [ask; DEPTH10_LEN],
2063 [0; DEPTH10_LEN],
2064 [1; DEPTH10_LEN],
2065 0,
2066 1,
2067 1.into(),
2068 1.into(),
2069 );
2070
2071 let result = book_depths_to_arrow_record_batch_bytes(&[depth]).unwrap();
2072
2073 assert_eq!(
2074 result.schema().metadata().get(KEY_PRICE_PRECISION).unwrap(),
2075 "2"
2076 );
2077 assert_eq!(
2078 result.schema().metadata().get(KEY_SIZE_PRECISION).unwrap(),
2079 "2"
2080 );
2081 }
2082
2083 #[rstest]
2084 fn test_deltas_to_arrow_record_batch_skips_leading_clears_for_precision() {
2085 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2086 let first = OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into());
2087 let second = OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into());
2088 let third = OrderBookDelta::new(
2089 instrument_id,
2090 BookAction::Add,
2091 BookOrder::new(
2092 OrderSide::Buy,
2093 Price::from("1.23"),
2094 Quantity::from("100.000000"),
2095 1,
2096 ),
2097 0,
2098 2,
2099 3.into(),
2100 3.into(),
2101 );
2102 let expected = vec![first, second, third];
2103
2104 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
2105 let metadata = batch.schema().metadata().clone();
2106 assert_eq!(
2107 metadata.get(KEY_PRICE_PRECISION).map(String::as_str),
2108 Some("2")
2109 );
2110 assert_eq!(
2111 metadata.get(KEY_SIZE_PRECISION).map(String::as_str),
2112 Some("6")
2113 );
2114
2115 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
2116
2117 assert_eq!(decoded, expected);
2118 assert_eq!(decoded[2].order.price.precision, 2);
2119 assert_eq!(decoded[2].order.size.precision, 6);
2120 }
2121
2122 #[rstest]
2123 fn test_deltas_to_arrow_record_batch_all_clear_roundtrip() {
2124 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2125 let expected = vec![
2126 OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into()),
2127 OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into()),
2128 ];
2129
2130 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
2131 let metadata = batch.schema().metadata().clone();
2132 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
2133
2134 assert_eq!(decoded, expected);
2135 }
2136
2137 #[rstest]
2138 fn test_deltas_to_arrow_record_batch_rejects_mixed_precision() {
2139 let instrument_id = InstrumentId::from("AUD/USD.SIM");
2140 let first = OrderBookDelta::new(
2141 instrument_id,
2142 BookAction::Add,
2143 BookOrder::new(
2144 OrderSide::Buy,
2145 Price::from("1.23"),
2146 Quantity::from("100.00"),
2147 1,
2148 ),
2149 0,
2150 1,
2151 1.into(),
2152 1.into(),
2153 );
2154 let second = OrderBookDelta::new(
2155 instrument_id,
2156 BookAction::Update,
2157 BookOrder::new(
2158 OrderSide::Buy,
2159 Price::from("1.234"),
2160 Quantity::from("100.000"),
2161 1,
2162 ),
2163 0,
2164 2,
2165 2.into(),
2166 2.into(),
2167 );
2168
2169 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
2170
2171 assert!(matches!(
2172 result,
2173 Err(EncodingError::MixedMetadata { index: 1 })
2174 ));
2175 }
2176
2177 #[rstest]
2178 fn test_deltas_to_arrow_record_batch_rejects_mixed_instruments() {
2179 let first = OrderBookDelta::clear(InstrumentId::from("AUD/USD.SIM"), 0, 1.into(), 1.into());
2180 let second = OrderBookDelta::new(
2181 InstrumentId::from("EUR/USD.SIM"),
2182 BookAction::Add,
2183 BookOrder::new(
2184 OrderSide::Buy,
2185 Price::from("1.23"),
2186 Quantity::from("100.00"),
2187 1,
2188 ),
2189 0,
2190 1,
2191 2.into(),
2192 2.into(),
2193 );
2194
2195 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
2196
2197 assert!(matches!(
2199 result,
2200 Err(EncodingError::MixedMetadata { index: 0 })
2201 ));
2202 }
2203}
2204
2205#[cfg(test)]
2206mod schema_invariant_tests {
2207 use arrow::{
2208 array::{Array, Decimal128Array},
2209 datatypes::{DataType, Field, Schema},
2210 };
2211 use nautilus_model::{
2212 data::{
2213 BookOrder, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate,
2214 OptionGreeks,
2215 bar::Bar,
2216 close::InstrumentClose,
2217 delta::OrderBookDelta,
2218 depth::OrderBookDepth,
2219 quote::QuoteTick,
2220 stubs::{stub_bar, stub_depth10},
2221 trade::TradeTick,
2222 },
2223 enums::{AggressorSide, BookAction, InstrumentCloseType, OrderSide},
2224 events::{
2225 AccountState, OrderAccepted, OrderCancelRejected, OrderCanceled, OrderDenied,
2226 OrderEmulated, OrderExpired, OrderFillVoided, OrderFilled, OrderInitialized,
2227 OrderModifyRejected, OrderPendingCancel, OrderPendingUpdate, OrderRejected,
2228 OrderReleased, OrderSnapshot, OrderSubmitted, OrderTriggered, OrderUpdated,
2229 PositionAdjusted, PositionChanged, PositionClosed, PositionOpened, PositionSnapshot,
2230 },
2231 identifiers::{InstrumentId, TradeId},
2232 instruments::{
2233 InstrumentAny, betting::BettingInstrument, binary_option::BinaryOption, cfd::Cfd,
2234 commodity::Commodity, crypto_future::CryptoFuture,
2235 crypto_futures_spread::CryptoFuturesSpread, crypto_option::CryptoOption,
2236 crypto_option_spread::CryptoOptionSpread, crypto_perpetual::CryptoPerpetual,
2237 currency_pair::CurrencyPair, equity::Equity, futures_contract::FuturesContract,
2238 futures_spread::FuturesSpread, index_instrument::IndexInstrument,
2239 option_contract::OptionContract, option_spread::OptionSpread,
2240 perpetual_contract::PerpetualContract, tokenized_asset::TokenizedAsset,
2241 },
2242 reports::{ExecutionMassStatus, FillReport, OrderStatusReport, PositionStatusReport},
2243 types::{PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity},
2244 };
2245 use rstest::rstest;
2246 use rust_decimal::Decimal;
2247
2248 use super::{
2249 ArrowSchemaProvider, DecodeFromRecordBatch, EncodeToRecordBatch, FIXED_DECIMAL_PRECISION,
2250 FIXED_DECIMAL_SCALE, KEY_IDENTIFIER, QUANTITY_RAW_MAX, decimal_to_arrow, decode_decimal,
2251 decode_decimal_price, fixed_decimal_data_type, is_timestamp_field, price_decimal_array,
2252 quantity_decimal_array, timestamp_data_type,
2253 };
2254
2255 #[derive(Clone, Copy, Debug)]
2256 enum FixedFamily {
2257 Quote,
2258 Trade,
2259 Bar,
2260 Delta,
2261 Depth,
2262 MarkPrice,
2263 IndexPrice,
2264 Close,
2265 }
2266
2267 #[rstest]
2268 fn decimal_to_arrow_rejects_values_outside_decode_range() {
2269 let value = Decimal::from_i128_with_scale(8_000_000_000_000, 0);
2270
2271 let error = decimal_to_arrow(&value, "amount").unwrap_err();
2272
2273 assert!(error.to_string().contains("96-bit range"));
2274 }
2275
2276 #[rstest]
2277 fn decimal_to_arrow_normalizes_trailing_zero_scale() {
2278 let value = Decimal::from_i128_with_scale(10_000_000_000_000_000, 18);
2279 let encoded = decimal_to_arrow(&value, "amount").unwrap();
2280 let array = Decimal128Array::from(vec![encoded])
2281 .with_precision_and_scale(FIXED_DECIMAL_PRECISION, FIXED_DECIMAL_SCALE)
2282 .unwrap();
2283
2284 assert_eq!(
2285 decode_decimal(&array, "amount", 0).unwrap(),
2286 value.normalize()
2287 );
2288 }
2289
2290 macro_rules! collect_data_schemas {
2291 ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
2292 vec![
2293 $(
2294 (stringify!($type), <$type as ArrowSchemaProvider>::get_schema(None)),
2295 )+
2296 ]
2297 };
2298 }
2299
2300 macro_rules! assert_model_field_map {
2301 (InstrumentAny) => {};
2303 (InstrumentStatus) => {};
2305 (OptionGreeks) => {};
2307 (OrderBookDelta) => {
2308 assert_fields_match_schema(
2309 catalog_field_map(OrderBookDelta::get_fields()),
2310 &OrderBookDelta::get_schema(None),
2311 &["price", "size", KEY_IDENTIFIER],
2312 );
2313 };
2314 (OrderBookDepth) => {
2315 assert_fields_match_schema(
2316 catalog_field_map(OrderBookDepth::get_fields()),
2317 &OrderBookDepth::get_schema(None),
2318 &[KEY_IDENTIFIER],
2319 );
2320 };
2321 (QuoteTick) => {
2322 assert_fields_match_schema(
2323 catalog_field_map(QuoteTick::get_fields()),
2324 &QuoteTick::get_schema(None),
2325 &[KEY_IDENTIFIER],
2326 );
2327 };
2328 (TradeTick) => {
2329 assert_fields_match_schema(
2330 catalog_field_map(TradeTick::get_fields()),
2331 &TradeTick::get_schema(None),
2332 &[KEY_IDENTIFIER],
2333 );
2334 };
2335 (Bar) => {
2336 assert_fields_match_schema(
2337 catalog_field_map(Bar::get_fields()),
2338 &Bar::get_schema(None),
2339 &[KEY_IDENTIFIER],
2340 );
2341 };
2342 (MarkPriceUpdate) => {
2343 assert_fields_match_schema(
2344 catalog_field_map(MarkPriceUpdate::get_fields()),
2345 &MarkPriceUpdate::get_schema(None),
2346 &["value", KEY_IDENTIFIER],
2347 );
2348 };
2349 (IndexPriceUpdate) => {
2350 assert_fields_match_schema(
2351 catalog_field_map(IndexPriceUpdate::get_fields()),
2352 &IndexPriceUpdate::get_schema(None),
2353 &["value", KEY_IDENTIFIER],
2354 );
2355 };
2356 (FundingRateUpdate) => {
2357 assert_fields_match_schema(
2358 catalog_field_map(FundingRateUpdate::get_fields()),
2359 &FundingRateUpdate::get_schema(None),
2360 &["interval", "next_funding_ns", KEY_IDENTIFIER],
2361 );
2362 };
2363 (InstrumentClose) => {
2364 assert_fields_match_schema(
2365 catalog_field_map(InstrumentClose::get_fields()),
2366 &InstrumentClose::get_schema(None),
2367 &["close_price", KEY_IDENTIFIER],
2368 );
2369 };
2370 }
2371
2372 macro_rules! assert_registered_model_field_maps {
2373 ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
2374 $(assert_model_field_map!($type);)+
2375 };
2376 }
2377
2378 #[rstest]
2379 fn registered_write_schemas_have_no_opaque_byte_fields() {
2380 let mut schemas = nautilus_model::for_each_data_type!(collect_data_schemas);
2381 schemas.extend(instrument_schemas());
2382 schemas.extend(record_schemas());
2383
2384 for (name, schema) in schemas {
2385 assert_open_schema(name, &schema);
2386 }
2387 }
2388
2389 #[rstest]
2390 fn model_field_maps_match_encoder_schemas() {
2391 nautilus_model::for_each_data_type!(assert_registered_model_field_maps);
2392 }
2393
2394 #[rstest]
2395 fn fixed_point_storage_uses_uniform_scale() {
2396 let price = Price::from("1.23456789");
2397 let array = price_decimal_array([price.raw()], "price").unwrap();
2398
2399 assert_eq!(array.data_type(), &fixed_decimal_data_type());
2400 assert_eq!(
2401 decode_decimal_price(&array, price.precision, "price", 0).unwrap(),
2402 price,
2403 );
2404 }
2405
2406 #[rstest]
2407 fn undefined_fixed_point_values_encode_as_null() {
2408 let prices = price_decimal_array([PRICE_UNDEF], "price").unwrap();
2409 let quantities = quantity_decimal_array([QUANTITY_UNDEF], "quantity").unwrap();
2410
2411 assert!(prices.is_null(0));
2412 assert!(quantities.is_null(0));
2413 }
2414
2415 #[rstest]
2416 fn price_error_fails_with_field_and_value() {
2417 let error = price_decimal_array([PRICE_ERROR], "bid_price").unwrap_err();
2418
2419 assert_eq!(
2420 error.to_string(),
2421 format!(
2422 "Invalid argument error: Price field 'bid_price' contains PRICE_ERROR raw value {PRICE_ERROR}"
2423 ),
2424 );
2425 }
2426
2427 #[rstest]
2428 fn quantity_overflow_fails_with_field_and_value() {
2429 let raw = QUANTITY_RAW_MAX + 1;
2430 let error = quantity_decimal_array([raw], "bid_size").unwrap_err();
2431
2432 assert_eq!(
2433 error.to_string(),
2434 format!(
2435 "Invalid argument error: Quantity field 'bid_size' raw value {raw} exceeds QUANTITY_RAW_MAX={QUANTITY_RAW_MAX}"
2436 ),
2437 );
2438 }
2439
2440 #[cfg(not(feature = "high-precision"))]
2441 #[rstest]
2442 fn standard_precision_decode_rejects_nonzero_scale_remainder() {
2443 let array = Decimal128Array::from(vec![1_i128])
2444 .with_precision_and_scale(38, 16)
2445 .unwrap();
2446
2447 let error = decode_decimal_price(&array, 9, "bid_price", 0).unwrap_err();
2448
2449 assert_eq!(
2450 error.to_string(),
2451 "Error parsing `bid_price`: row 0: decimal value 1 has nonzero digits beyond build \
2452 precision 9",
2453 );
2454 }
2455
2456 #[cfg(feature = "high-precision")]
2457 #[rstest]
2458 fn high_precision_decimal_payload_is_bit_exact() {
2459 let raw = 1_234_567_890_123_456_i128;
2460 let array = price_decimal_array([raw], "price").unwrap();
2461
2462 assert_eq!(array.value(0), raw);
2463 assert_eq!(
2464 decode_decimal_price(&array, 16, "price", 0).unwrap().raw(),
2465 raw
2466 );
2467 }
2468
2469 #[rstest]
2470 #[case::quote(FixedFamily::Quote)]
2471 #[case::trade(FixedFamily::Trade)]
2472 #[case::bar(FixedFamily::Bar)]
2473 #[case::delta(FixedFamily::Delta)]
2474 #[case::depth(FixedFamily::Depth)]
2475 #[case::mark_price(FixedFamily::MarkPrice)]
2476 #[case::index_price(FixedFamily::IndexPrice)]
2477 #[case::close(FixedFamily::Close)]
2478 fn fixed_family_round_trips_multiple_precisions(#[case] family: FixedFamily) {
2479 round_trip_fixed_family(family, Price::from("1"), Quantity::from("2"));
2480 round_trip_fixed_family(family, Price::from("1.23456"), Quantity::from("2.34567"));
2481 #[cfg(feature = "high-precision")]
2482 round_trip_fixed_family(
2483 family,
2484 Price::from("1.2345678901234567"),
2485 Quantity::from("2.3456789012345678"),
2486 );
2487 }
2488
2489 #[rstest]
2490 #[case::quote(FixedFamily::Quote)]
2491 #[case::trade(FixedFamily::Trade)]
2492 #[case::bar(FixedFamily::Bar)]
2493 #[case::delta(FixedFamily::Delta)]
2494 #[case::mark_price(FixedFamily::MarkPrice)]
2495 #[case::index_price(FixedFamily::IndexPrice)]
2496 #[case::close(FixedFamily::Close)]
2497 fn fixed_family_sentinel_encoding(#[case] family: FixedFamily) {
2498 let instrument_id = InstrumentId::from("SENTINEL.TEST");
2499 let price = Price::from_raw(PRICE_UNDEF, 0);
2500 let quantity = Quantity::from_raw(QUANTITY_UNDEF, 0);
2501
2502 match family {
2503 FixedFamily::Quote => {
2504 let value = QuoteTick::new(
2505 instrument_id,
2506 price,
2507 Price::from("1"),
2508 quantity,
2509 Quantity::from("1"),
2510 1.into(),
2511 2.into(),
2512 );
2513 let metadata = QuoteTick::get_metadata(&instrument_id, 0, 0);
2514 let error = QuoteTick::encode_batch(&metadata, &[value]).unwrap_err();
2515 assert!(error.to_string().contains("bid_price"));
2516 assert!(error.to_string().contains("PRICE_UNDEF"));
2517 }
2518 FixedFamily::Trade => {
2519 let value = TradeTick {
2520 instrument_id,
2521 price,
2522 size: quantity,
2523 aggressor_side: AggressorSide::Buy,
2524 trade_id: TradeId::from("sentinel"),
2525 ts_event: 1.into(),
2526 ts_init: 2.into(),
2527 };
2528 let metadata = TradeTick::get_metadata(&instrument_id, 0, 0);
2529 let error = TradeTick::encode_batch(&metadata, &[value]).unwrap_err();
2530 assert!(error.to_string().contains("price"));
2531 assert!(error.to_string().contains("PRICE_UNDEF"));
2532 }
2533 FixedFamily::Bar => {
2534 let mut value = stub_bar();
2535 value.open = price;
2536 value.volume = quantity;
2537 let metadata = Bar::get_metadata(&value.bar_type, 0, 0);
2538 let error = Bar::encode_batch(&metadata, &[value]).unwrap_err();
2539 assert!(error.to_string().contains("open"));
2540 assert!(error.to_string().contains("PRICE_UNDEF"));
2541 }
2542 FixedFamily::Delta => {
2543 let value = OrderBookDelta {
2544 instrument_id,
2545 action: BookAction::Update,
2546 order: BookOrder {
2547 side: OrderSide::Buy.into(),
2548 price,
2549 size: quantity,
2550 order_id: 1,
2551 },
2552 flags: 0,
2553 sequence: 1,
2554 ts_event: 1.into(),
2555 ts_init: 2.into(),
2556 };
2557 let metadata = OrderBookDelta::get_metadata(&instrument_id, 0, 0);
2558 let batch = OrderBookDelta::encode_batch(&metadata, &[value]).unwrap();
2559 assert!(batch.column_by_name("price").unwrap().is_null(0));
2560 assert!(batch.column_by_name("size").unwrap().is_null(0));
2561 assert_eq!(
2562 OrderBookDelta::decode_batch(&metadata, batch).unwrap(),
2563 vec![value],
2564 );
2565 }
2566 FixedFamily::Depth => unreachable!("depth sides omit absent levels"),
2567 FixedFamily::MarkPrice => {
2568 let value = MarkPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2569 let metadata = MarkPriceUpdate::get_metadata(&instrument_id, 0);
2570 let batch = MarkPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2571 assert!(batch.column_by_name("value").unwrap().is_null(0));
2572 assert_eq!(
2573 MarkPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2574 vec![value],
2575 );
2576 }
2577 FixedFamily::IndexPrice => {
2578 let value = IndexPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2579 let metadata = IndexPriceUpdate::get_metadata(&instrument_id, 0);
2580 let batch = IndexPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2581 assert!(batch.column_by_name("value").unwrap().is_null(0));
2582 assert_eq!(
2583 IndexPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2584 vec![value],
2585 );
2586 }
2587 FixedFamily::Close => {
2588 let value = InstrumentClose::new(
2589 instrument_id,
2590 price,
2591 InstrumentCloseType::EndOfSession,
2592 1.into(),
2593 2.into(),
2594 );
2595 let metadata = InstrumentClose::get_metadata(&instrument_id, 0);
2596 let batch = InstrumentClose::encode_batch(&metadata, &[value]).unwrap();
2597 assert!(batch.column_by_name("close_price").unwrap().is_null(0));
2598 assert_eq!(
2599 InstrumentClose::decode_batch(&metadata, batch).unwrap(),
2600 vec![value],
2601 );
2602 }
2603 }
2604 }
2605
2606 fn round_trip_fixed_family(family: FixedFamily, price: Price, quantity: Quantity) {
2607 let instrument_id = InstrumentId::from("PRECISION.TEST");
2608
2609 match family {
2610 FixedFamily::Quote => {
2611 let value = QuoteTick::new(
2612 instrument_id,
2613 price,
2614 price,
2615 quantity,
2616 quantity,
2617 1.into(),
2618 2.into(),
2619 );
2620 let metadata =
2621 QuoteTick::get_metadata(&instrument_id, price.precision, quantity.precision);
2622 let batch = QuoteTick::encode_batch(&metadata, &[value]).unwrap();
2623 assert_eq!(
2624 QuoteTick::decode_batch(&metadata, batch).unwrap(),
2625 vec![value],
2626 );
2627 }
2628 FixedFamily::Trade => {
2629 let value = TradeTick::new(
2630 instrument_id,
2631 price,
2632 quantity,
2633 AggressorSide::Buy,
2634 TradeId::from("precision"),
2635 1.into(),
2636 2.into(),
2637 );
2638 let metadata =
2639 TradeTick::get_metadata(&instrument_id, price.precision, quantity.precision);
2640 let batch = TradeTick::encode_batch(&metadata, &[value]).unwrap();
2641 assert_eq!(
2642 TradeTick::decode_batch(&metadata, batch).unwrap(),
2643 vec![value],
2644 );
2645 }
2646 FixedFamily::Bar => {
2647 let mut value = stub_bar();
2648 value.open = price;
2649 value.high = price;
2650 value.low = price;
2651 value.close = price;
2652 value.volume = quantity;
2653 let metadata =
2654 Bar::get_metadata(&value.bar_type, price.precision, quantity.precision);
2655 let batch = Bar::encode_batch(&metadata, &[value]).unwrap();
2656 assert_eq!(Bar::decode_batch(&metadata, batch).unwrap(), vec![value]);
2657 }
2658 FixedFamily::Delta => {
2659 let value = OrderBookDelta::new(
2660 instrument_id,
2661 BookAction::Add,
2662 BookOrder {
2663 side: OrderSide::Buy.into(),
2664 price,
2665 size: quantity,
2666 order_id: 1,
2667 },
2668 0,
2669 1,
2670 1.into(),
2671 2.into(),
2672 );
2673 let metadata = OrderBookDelta::get_metadata(
2674 &instrument_id,
2675 price.precision,
2676 quantity.precision,
2677 );
2678 let batch = OrderBookDelta::encode_batch(&metadata, &[value]).unwrap();
2679 assert_eq!(
2680 OrderBookDelta::decode_batch(&metadata, batch).unwrap(),
2681 vec![value],
2682 );
2683 }
2684 FixedFamily::Depth => {
2685 let mut value = stub_depth10();
2686 for order in value.bids.iter_mut().chain(value.asks.iter_mut()) {
2687 order.price = price;
2688 order.size = quantity;
2689 }
2690 let metadata = OrderBookDepth::get_metadata(
2691 &value.instrument_id,
2692 price.precision,
2693 quantity.precision,
2694 );
2695 let batch = OrderBookDepth::encode_batch(&metadata, &[value.clone()]).unwrap();
2696 assert_eq!(
2697 OrderBookDepth::decode_batch(&metadata, batch).unwrap(),
2698 vec![value],
2699 );
2700 }
2701 FixedFamily::MarkPrice => {
2702 let value = MarkPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2703 let metadata = MarkPriceUpdate::get_metadata(&instrument_id, price.precision);
2704 let batch = MarkPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2705 assert_eq!(
2706 MarkPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2707 vec![value],
2708 );
2709 }
2710 FixedFamily::IndexPrice => {
2711 let value = IndexPriceUpdate::new(instrument_id, price, 1.into(), 2.into());
2712 let metadata = IndexPriceUpdate::get_metadata(&instrument_id, price.precision);
2713 let batch = IndexPriceUpdate::encode_batch(&metadata, &[value]).unwrap();
2714 assert_eq!(
2715 IndexPriceUpdate::decode_batch(&metadata, batch).unwrap(),
2716 vec![value],
2717 );
2718 }
2719 FixedFamily::Close => {
2720 let value = InstrumentClose::new(
2721 instrument_id,
2722 price,
2723 InstrumentCloseType::EndOfSession,
2724 1.into(),
2725 2.into(),
2726 );
2727 let metadata = InstrumentClose::get_metadata(&instrument_id, price.precision);
2728 let batch = InstrumentClose::encode_batch(&metadata, &[value]).unwrap();
2729 assert_eq!(
2730 InstrumentClose::decode_batch(&metadata, batch).unwrap(),
2731 vec![value],
2732 );
2733 }
2734 }
2735 }
2736
2737 fn instrument_schemas() -> Vec<(&'static str, Schema)> {
2738 vec![
2740 schema::<BettingInstrument>(),
2741 schema::<BinaryOption>(),
2742 schema::<Cfd>(),
2743 schema::<Commodity>(),
2744 schema::<CryptoFuture>(),
2745 schema::<CryptoFuturesSpread>(),
2746 schema::<CryptoOption>(),
2747 schema::<CryptoOptionSpread>(),
2748 schema::<CryptoPerpetual>(),
2749 schema::<CurrencyPair>(),
2750 schema::<Equity>(),
2751 schema::<FuturesContract>(),
2752 schema::<FuturesSpread>(),
2753 schema::<IndexInstrument>(),
2754 schema::<OptionContract>(),
2755 schema::<OptionSpread>(),
2756 schema::<PerpetualContract>(),
2757 schema::<TokenizedAsset>(),
2758 ]
2759 }
2760
2761 fn record_schemas() -> Vec<(&'static str, Schema)> {
2762 vec![
2764 schema::<AccountState>(),
2765 schema::<OrderInitialized>(),
2766 schema::<OrderDenied>(),
2767 schema::<OrderEmulated>(),
2768 schema::<OrderSubmitted>(),
2769 schema::<OrderAccepted>(),
2770 schema::<OrderRejected>(),
2771 schema::<OrderPendingCancel>(),
2772 schema::<OrderCanceled>(),
2773 schema::<OrderCancelRejected>(),
2774 schema::<OrderExpired>(),
2775 schema::<OrderTriggered>(),
2776 schema::<OrderPendingUpdate>(),
2777 schema::<OrderReleased>(),
2778 schema::<OrderModifyRejected>(),
2779 schema::<OrderUpdated>(),
2780 schema::<OrderFilled>(),
2781 schema::<OrderFillVoided>(),
2782 schema::<PositionOpened>(),
2783 schema::<PositionChanged>(),
2784 schema::<PositionClosed>(),
2785 schema::<PositionAdjusted>(),
2786 schema::<OrderStatusReport>(),
2787 schema::<FillReport>(),
2788 schema::<PositionStatusReport>(),
2789 schema::<ExecutionMassStatus>(),
2790 schema::<OrderSnapshot>(),
2791 schema::<PositionSnapshot>(),
2792 ]
2793 }
2794
2795 fn schema<T: ArrowSchemaProvider>() -> (&'static str, Schema) {
2796 (std::any::type_name::<T>(), T::get_schema(None))
2797 }
2798
2799 fn catalog_field_map(
2800 fields: impl IntoIterator<Item = (String, String)>,
2801 ) -> Vec<(String, String)> {
2802 let mut fields = fields.into_iter().collect::<Vec<_>>();
2803 fields.push((KEY_IDENTIFIER.to_string(), "Utf8".to_string()));
2804 fields
2805 }
2806
2807 fn assert_fields_match_schema(
2808 expected: Vec<(String, String)>,
2809 schema: &Schema,
2810 nullable: &[&str],
2811 ) {
2812 let expected = expected
2813 .into_iter()
2814 .map(|(name, data_type)| {
2815 let is_nullable = nullable.contains(&name.as_str());
2816 (name, data_type, is_nullable)
2817 })
2818 .collect::<Vec<_>>();
2819 let actual: Vec<_> = schema
2820 .fields()
2821 .iter()
2822 .map(|field| {
2823 (
2824 field.name().clone(),
2825 arrow_type_name(field.data_type()),
2826 field.is_nullable(),
2827 )
2828 })
2829 .collect();
2830
2831 assert_eq!(actual, expected);
2832 }
2833
2834 fn arrow_type_name(data_type: &DataType) -> String {
2835 match data_type {
2836 DataType::List(field) => format!("List({})", arrow_type_name(field.data_type())),
2837 DataType::Struct(fields) => {
2838 let fields = fields
2839 .iter()
2840 .map(|field| {
2841 format!("{}: {}", field.name(), arrow_type_name(field.data_type()))
2842 })
2843 .collect::<Vec<_>>()
2844 .join(", ");
2845 format!("Struct({fields})")
2846 }
2847 _ => format!("{data_type:?}"),
2848 }
2849 }
2850
2851 fn assert_open_schema(name: &str, schema: &Schema) {
2852 for field in schema.fields() {
2853 assert_open_field(name, field);
2854 }
2855 }
2856
2857 fn assert_open_field(schema_name: &str, field: &Field) {
2858 if is_timestamp_field(field.name()) {
2859 assert_eq!(
2860 field.data_type(),
2861 ×tamp_data_type(),
2862 "write schema `{schema_name}` timestamp field `{}` is not a UTC nanosecond timestamp",
2863 field.name(),
2864 );
2865 }
2866
2867 match field.data_type() {
2868 DataType::Binary
2869 | DataType::LargeBinary
2870 | DataType::BinaryView
2871 | DataType::FixedSizeBinary(_) => {
2872 panic!(
2873 "write schema `{schema_name}` contains opaque byte field `{}`: {}",
2874 field.name(),
2875 field.data_type(),
2876 );
2877 }
2878 DataType::List(child)
2879 | DataType::LargeList(child)
2880 | DataType::ListView(child)
2881 | DataType::LargeListView(child)
2882 | DataType::FixedSizeList(child, _)
2883 | DataType::Map(child, _) => assert_open_field(schema_name, child),
2884 DataType::Struct(children) => {
2885 for child in children {
2886 assert_open_field(schema_name, child);
2887 }
2888 }
2889 _ => {}
2890 }
2891 }
2892}