1pub mod account_state;
19pub mod bar;
20pub mod close;
21pub mod custom;
22pub mod delta;
23pub mod depth;
24pub mod funding;
25pub mod index_price;
26pub mod instrument;
27pub mod instrument_status;
28pub mod json;
29pub mod mark_price;
30pub mod option_greeks;
31pub mod order_event;
32pub mod position_event;
33pub mod quote;
34pub mod report;
35pub mod snapshot;
36pub mod trade;
37
38#[cfg(feature = "arrow-display")]
39pub mod display;
40
41use std::{
42 collections::HashMap,
43 io::{self, Write},
44 str::FromStr,
45};
46
47use arrow::{
48 array::{
49 Array, ArrayRef, BinaryArray, BinaryViewArray, FixedSizeBinaryArray, StringArray,
50 StringViewArray,
51 },
52 datatypes::{DataType, Schema},
53 error::ArrowError,
54 ipc::writer::StreamWriter,
55 record_batch::RecordBatch,
56};
57use nautilus_model::{
58 data::{
59 Data, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, bar::Bar,
60 close::InstrumentClose, delta::OrderBookDelta, depth::OrderBookDepth10,
61 option_chain::OptionGreeks, quote::QuoteTick, trade::TradeTick,
62 },
63 enums::BookAction,
64 identifiers::InstrumentId,
65 types::{
66 PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity,
67 fixed::{PRECISION_BYTES, correct_price_raw, correct_quantity_raw},
68 price::PriceRaw,
69 quantity::QuantityRaw,
70 },
71};
72#[cfg(feature = "python")]
73use pyo3::prelude::*;
74use ustr::Ustr;
75
76const KEY_BAR_TYPE: &str = "bar_type";
78pub const KEY_INSTRUMENT_ID: &str = "instrument_id";
79pub const KEY_PRICE_PRECISION: &str = "price_precision";
80pub const KEY_SIZE_PRECISION: &str = "size_precision";
81
82#[derive(thiserror::Error, Debug)]
83pub enum DataStreamingError {
84 #[error("I/O error: {0}")]
85 IoError(#[from] io::Error),
86 #[error("Arrow error: {0}")]
87 ArrowError(#[from] arrow::error::ArrowError),
88 #[cfg(feature = "python")]
89 #[error("Python error: {0}")]
90 PythonError(#[from] PyErr),
91}
92
93#[derive(thiserror::Error, Debug)]
94pub enum EncodingError {
95 #[error("Empty data")]
96 EmptyData,
97 #[error(
98 "Mixed metadata at row {index}; encode each instrument, bar type, or precision separately"
99 )]
100 MixedMetadata { index: usize },
101 #[error("Missing metadata key: `{0}`")]
102 MissingMetadata(&'static str),
103 #[error("Missing data column: `{0}` at index {1}")]
104 MissingColumn(&'static str, usize),
105 #[error("Error parsing `{0}`: {1}")]
106 ParseError(&'static str, String),
107 #[error("Invalid column type `{0}` at index {1}: expected {2}, found {3}")]
108 InvalidColumnType(&'static str, usize, DataType, DataType),
109 #[error(
110 "Precision mode mismatch for `{field}`: catalog data has {actual_bytes} byte values, \
111 but this build expects {expected_bytes} bytes. The catalog was created with a different \
112 precision mode (standard=8 bytes, high=16 bytes). Rebuild the catalog or change your \
113 build's precision mode. See: https://nautilustrader.io/docs/latest/getting_started/installation/#precision-mode"
114 )]
115 PrecisionMismatch {
116 field: &'static str,
117 expected_bytes: i32,
118 actual_bytes: i32,
119 },
120 #[error("Arrow error: {0}")]
121 ArrowError(#[from] arrow::error::ArrowError),
122}
123
124#[inline]
125fn get_raw_price(bytes: &[u8]) -> PriceRaw {
126 PriceRaw::from_le_bytes(
127 bytes
128 .try_into()
129 .expect("Price raw bytes must be exactly the size of PriceRaw"),
130 )
131}
132
133#[inline]
134fn get_raw_quantity(bytes: &[u8]) -> QuantityRaw {
135 QuantityRaw::from_le_bytes(
136 bytes
137 .try_into()
138 .expect("Quantity raw bytes must be exactly the size of QuantityRaw"),
139 )
140}
141
142#[inline]
150fn get_corrected_raw_price(bytes: &[u8], precision: u8) -> PriceRaw {
151 let raw = get_raw_price(bytes);
152
153 if raw == PRICE_UNDEF || raw == PRICE_ERROR {
155 return raw;
156 }
157
158 correct_price_raw(raw, precision)
159}
160
161#[inline]
169fn get_corrected_raw_quantity(bytes: &[u8], precision: u8) -> QuantityRaw {
170 let raw = get_raw_quantity(bytes);
171
172 if raw == QUANTITY_UNDEF {
174 return raw;
175 }
176
177 correct_quantity_raw(raw, precision)
178}
179
180pub fn decode_price(
189 bytes: &[u8],
190 precision: u8,
191 field: &'static str,
192 row: usize,
193) -> Result<Price, EncodingError> {
194 let raw = get_corrected_raw_price(bytes, precision);
195 Price::from_raw_checked(raw, precision)
196 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
197}
198
199pub fn decode_quantity(
208 bytes: &[u8],
209 precision: u8,
210 field: &'static str,
211 row: usize,
212) -> Result<Quantity, EncodingError> {
213 let raw = get_corrected_raw_quantity(bytes, precision);
214 Quantity::from_raw_checked(raw, precision)
215 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
216}
217
218pub fn decode_price_with_sentinel(
226 bytes: &[u8],
227 precision: u8,
228 field: &'static str,
229 row: usize,
230) -> Result<Price, EncodingError> {
231 let raw = get_raw_price(bytes);
232 let (final_raw, final_precision) = if raw == PRICE_UNDEF {
233 (raw, 0)
234 } else {
235 (get_corrected_raw_price(bytes, precision), precision)
236 };
237 Price::from_raw_checked(final_raw, final_precision)
238 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
239}
240
241pub fn decode_quantity_with_sentinel(
249 bytes: &[u8],
250 precision: u8,
251 field: &'static str,
252 row: usize,
253) -> Result<Quantity, EncodingError> {
254 let raw = get_raw_quantity(bytes);
255 let (final_raw, final_precision) = if raw == QUANTITY_UNDEF {
256 (raw, 0)
257 } else {
258 (get_corrected_raw_quantity(bytes, precision), precision)
259 };
260 Quantity::from_raw_checked(final_raw, final_precision)
261 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
262}
263
264pub trait ArrowSchemaProvider {
266 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema;
268
269 #[must_use]
271 fn get_schema_map() -> HashMap<String, String> {
272 let schema = Self::get_schema(None);
273 let mut map = HashMap::new();
274
275 for field in schema.fields() {
276 let name = field.name().clone();
277 let data_type = format!("{:?}", field.data_type());
278 map.insert(name, data_type);
279 }
280 map
281 }
282}
283
284pub trait EncodeToRecordBatch
286where
287 Self: Sized + ArrowSchemaProvider,
288{
289 fn encode_batch(
295 metadata: &HashMap<String, String>,
296 data: &[Self],
297 ) -> Result<RecordBatch, ArrowError>;
298
299 fn metadata(&self) -> HashMap<String, String>;
301
302 fn chunk_metadata(chunk: &[Self]) -> HashMap<String, String> {
311 chunk
312 .first()
313 .map(Self::metadata)
314 .expect("Chunk must have at least one element to encode")
315 }
316}
317
318pub trait DecodeFromRecordBatch
320where
321 Self: Sized + Into<Data> + ArrowSchemaProvider,
322{
323 fn decode_batch(
329 metadata: &HashMap<String, String>,
330 record_batch: RecordBatch,
331 ) -> Result<Vec<Self>, EncodingError>;
332}
333
334pub trait DecodeTypedFromRecordBatch
336where
337 Self: Sized + ArrowSchemaProvider,
338{
339 fn decode_typed_batch(
345 metadata: &HashMap<String, String>,
346 record_batch: RecordBatch,
347 ) -> Result<Vec<Self>, EncodingError>;
348}
349
350impl<T> DecodeTypedFromRecordBatch for T
351where
352 T: DecodeFromRecordBatch,
353{
354 fn decode_typed_batch(
355 metadata: &HashMap<String, String>,
356 record_batch: RecordBatch,
357 ) -> Result<Vec<Self>, EncodingError> {
358 Self::decode_batch(metadata, record_batch)
359 }
360}
361
362pub trait DecodeDataFromRecordBatch
364where
365 Self: Sized + ArrowSchemaProvider,
366{
367 fn decode_data_batch(
373 metadata: &HashMap<String, String>,
374 record_batch: RecordBatch,
375 ) -> Result<Vec<Data>, EncodingError>;
376}
377
378pub trait WriteStream {
380 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError>;
386}
387
388impl<T: Write> WriteStream for T {
389 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError> {
390 let mut writer = StreamWriter::try_new(self, &record_batch.schema())?;
391 writer.write(record_batch)?;
392 writer.finish()?;
393 Ok(())
394 }
395}
396
397pub fn extract_column_string<'a>(
406 cols: &'a [ArrayRef],
407 column_key: &'static str,
408 column_index: usize,
409) -> Result<StringColumnRef<'a>, EncodingError> {
410 let column_values = cols
411 .get(column_index)
412 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
413 let dt = column_values.data_type();
414 if let Some(arr) = column_values.as_any().downcast_ref::<StringArray>() {
415 Ok(StringColumnRef::Utf8(arr))
416 } else if let Some(arr) = column_values.as_any().downcast_ref::<StringViewArray>() {
417 Ok(StringColumnRef::Utf8View(arr))
418 } else {
419 Err(EncodingError::InvalidColumnType(
420 column_key,
421 column_index,
422 DataType::Utf8,
423 dt.clone(),
424 ))
425 }
426}
427
428#[derive(Debug)]
430pub enum StringColumnRef<'a> {
431 Utf8(&'a StringArray),
432 Utf8View(&'a StringViewArray),
433}
434
435impl StringColumnRef<'_> {
436 #[inline]
438 #[must_use]
439 pub fn value(&self, i: usize) -> &str {
440 match self {
441 Self::Utf8(arr) => arr.value(i),
442 Self::Utf8View(arr) => arr.value(i),
443 }
444 }
445}
446
447pub fn extract_column_binary<'a>(
457 cols: &'a [ArrayRef],
458 column_key: &'static str,
459 column_index: usize,
460) -> Result<BinaryColumnRef<'a>, EncodingError> {
461 let column_values = cols
462 .get(column_index)
463 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
464 let dt = column_values.data_type();
465 if let Some(arr) = column_values.as_any().downcast_ref::<BinaryArray>() {
466 Ok(BinaryColumnRef::Binary(arr))
467 } else if let Some(arr) = column_values.as_any().downcast_ref::<BinaryViewArray>() {
468 Ok(BinaryColumnRef::BinaryView(arr))
469 } else {
470 Err(EncodingError::InvalidColumnType(
471 column_key,
472 column_index,
473 DataType::Binary,
474 dt.clone(),
475 ))
476 }
477}
478
479#[derive(Debug)]
481pub enum BinaryColumnRef<'a> {
482 Binary(&'a BinaryArray),
483 BinaryView(&'a BinaryViewArray),
484}
485
486impl BinaryColumnRef<'_> {
487 #[inline]
489 #[must_use]
490 pub fn value(&self, i: usize) -> &[u8] {
491 match self {
492 Self::Binary(arr) => arr.value(i),
493 Self::BinaryView(arr) => arr.value(i),
494 }
495 }
496}
497
498pub fn extract_column<'a, T: Array + 'static>(
506 cols: &'a [ArrayRef],
507 column_key: &'static str,
508 column_index: usize,
509 expected_type: DataType,
510) -> Result<&'a T, EncodingError> {
511 let column_values = cols
512 .get(column_index)
513 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
514 let downcasted_values =
515 column_values
516 .as_any()
517 .downcast_ref::<T>()
518 .ok_or(EncodingError::InvalidColumnType(
519 column_key,
520 column_index,
521 expected_type,
522 column_values.data_type().clone(),
523 ))?;
524 Ok(downcasted_values)
525}
526
527pub fn extract_column_by_name_or_index<'a, T: Array + 'static>(
533 record_batch: &'a RecordBatch,
534 column_key: &'static str,
535 fallback_index: usize,
536 expected_type: DataType,
537) -> Result<&'a T, EncodingError> {
538 let column_index = record_batch
539 .schema()
540 .index_of(column_key)
541 .unwrap_or(fallback_index);
542 extract_column::<T>(
543 record_batch.columns(),
544 column_key,
545 column_index,
546 expected_type,
547 )
548}
549
550pub fn extract_optional_string_column_by_name<'a>(
556 record_batch: &'a RecordBatch,
557 column_key: &'static str,
558) -> Result<Option<&'a StringArray>, EncodingError> {
559 let Ok(column_index) = record_batch.schema().index_of(column_key) else {
560 return Ok(None);
561 };
562 let column_values = record_batch
563 .columns()
564 .get(column_index)
565 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
566 let downcasted_values = column_values.as_any().downcast_ref::<StringArray>().ok_or(
567 EncodingError::InvalidColumnType(
568 column_key,
569 column_index,
570 DataType::Utf8,
571 column_values.data_type().clone(),
572 ),
573 )?;
574 Ok(Some(downcasted_values))
575}
576
577#[must_use]
579pub fn optional_ustr_value(values: Option<&StringArray>, row: usize) -> Option<Ustr> {
580 values.and_then(|column| (!column.is_null(row)).then(|| Ustr::from(column.value(row))))
581}
582
583pub(crate) fn parse_price_metadata(
589 metadata: &HashMap<String, String>,
590) -> Result<(InstrumentId, u8), EncodingError> {
591 Ok((
592 parse_instrument_id(metadata)?,
593 parse_precision(metadata, KEY_PRICE_PRECISION)?,
594 ))
595}
596
597pub(crate) fn parse_price_size_metadata(
603 metadata: &HashMap<String, String>,
604) -> Result<(InstrumentId, u8, u8), EncodingError> {
605 Ok((
606 parse_instrument_id(metadata)?,
607 parse_precision(metadata, KEY_PRICE_PRECISION)?,
608 parse_precision(metadata, KEY_SIZE_PRECISION)?,
609 ))
610}
611
612fn parse_instrument_id(metadata: &HashMap<String, String>) -> Result<InstrumentId, EncodingError> {
613 let value = metadata
614 .get(KEY_INSTRUMENT_ID)
615 .ok_or_else(|| EncodingError::MissingMetadata(KEY_INSTRUMENT_ID))?;
616
617 InstrumentId::from_str(value)
618 .map_err(|e| EncodingError::ParseError(KEY_INSTRUMENT_ID, e.to_string()))
619}
620
621pub(crate) fn parse_precision(
627 metadata: &HashMap<String, String>,
628 key: &'static str,
629) -> Result<u8, EncodingError> {
630 metadata
631 .get(key)
632 .ok_or_else(|| EncodingError::MissingMetadata(key))?
633 .parse::<u8>()
634 .map_err(|e| EncodingError::ParseError(key, e.to_string()))
635}
636
637pub fn validate_precision_bytes(
647 array: &FixedSizeBinaryArray,
648 field: &'static str,
649) -> Result<(), EncodingError> {
650 let actual = array.value_length();
651 if actual != PRECISION_BYTES {
652 return Err(EncodingError::PrecisionMismatch {
653 field,
654 expected_bytes: PRECISION_BYTES,
655 actual_bytes: actual,
656 });
657 }
658 Ok(())
659}
660
661pub fn book_deltas_to_arrow_record_batch_bytes(
671 data: &[OrderBookDelta],
672) -> Result<RecordBatch, EncodingError> {
673 let Some(first) = data.first() else {
674 return Err(EncodingError::EmptyData);
675 };
676
677 let metadata = OrderBookDelta::chunk_metadata(data);
678 let instrument_id = data
679 .iter()
680 .find(|delta| delta.action != BookAction::Clear)
681 .unwrap_or(first)
682 .instrument_id;
683
684 if let Some(index) = data.iter().position(|delta| {
685 delta.instrument_id != instrument_id
686 || (delta.action != BookAction::Clear && delta.metadata() != metadata)
687 }) {
688 return Err(EncodingError::MixedMetadata { index });
689 }
690
691 OrderBookDelta::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
692}
693
694pub fn book_depth10_to_arrow_record_batch_bytes(
703 data: &[OrderBookDepth10],
704) -> Result<RecordBatch, EncodingError> {
705 let Some(first) = data.first() else {
706 return Err(EncodingError::EmptyData);
707 };
708 let precision = data
709 .iter()
710 .flat_map(|depth| depth.bids.iter().chain(&depth.asks))
711 .find(|order| !order.price.is_undefined() && !order.size.is_undefined())
712 .map_or(
713 (first.bids[0].price.precision, first.bids[0].size.precision),
714 |order| (order.price.precision, order.size.precision),
715 );
716
717 if let Some(index) = data.iter().position(|depth| {
718 depth.instrument_id != first.instrument_id || !depth_precision_is_uniform(depth, precision)
719 }) {
720 return Err(EncodingError::MixedMetadata { index });
721 }
722
723 let metadata = OrderBookDepth10::get_metadata(&first.instrument_id, precision.0, precision.1);
724 OrderBookDepth10::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
725}
726
727fn depth_precision_is_uniform(depth: &OrderBookDepth10, precision: (u8, u8)) -> bool {
728 depth.bids.iter().chain(&depth.asks).all(|order| {
729 match (order.price.is_undefined(), order.size.is_undefined()) {
730 (true, true) => true,
731 (false, false) => {
732 order.price.precision == precision.0 && order.size.precision == precision.1
733 }
734 _ => false,
735 }
736 })
737}
738
739pub fn quotes_to_arrow_record_batch_bytes(
748 data: &[QuoteTick],
749) -> Result<RecordBatch, EncodingError> {
750 encode_batch_with_metadata(data)
751}
752
753pub fn trades_to_arrow_record_batch_bytes(
762 data: &[TradeTick],
763) -> Result<RecordBatch, EncodingError> {
764 encode_batch_with_metadata(data)
765}
766
767pub fn bars_to_arrow_record_batch_bytes(data: &[Bar]) -> Result<RecordBatch, EncodingError> {
776 encode_batch_with_metadata(data)
777}
778
779pub fn mark_prices_to_arrow_record_batch_bytes(
788 data: &[MarkPriceUpdate],
789) -> Result<RecordBatch, EncodingError> {
790 encode_batch_with_metadata(data)
791}
792
793pub fn index_prices_to_arrow_record_batch_bytes(
802 data: &[IndexPriceUpdate],
803) -> Result<RecordBatch, EncodingError> {
804 encode_batch_with_metadata(data)
805}
806
807pub fn instrument_status_to_arrow_record_batch_bytes(
815 data: &[InstrumentStatus],
816) -> Result<RecordBatch, EncodingError> {
817 let Some(first) = data.first() else {
818 return Err(EncodingError::EmptyData);
819 };
820
821 let metadata = first.metadata();
822 InstrumentStatus::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
823}
824
825pub fn option_greeks_to_arrow_record_batch_bytes(
833 data: &[OptionGreeks],
834) -> Result<RecordBatch, EncodingError> {
835 let Some(first) = data.first() else {
836 return Err(EncodingError::EmptyData);
837 };
838
839 let metadata = first.metadata();
840 OptionGreeks::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
841}
842
843pub fn instrument_closes_to_arrow_record_batch_bytes(
852 data: &[InstrumentClose],
853) -> Result<RecordBatch, EncodingError> {
854 encode_batch_with_metadata(data)
855}
856
857fn encode_batch_with_metadata<T>(data: &[T]) -> Result<RecordBatch, EncodingError>
858where
859 T: EncodeToRecordBatch,
860{
861 if data.is_empty() {
862 return Err(EncodingError::EmptyData);
863 }
864
865 let metadata = T::chunk_metadata(data);
866 if let Some(index) = data.iter().position(|value| value.metadata() != metadata) {
867 return Err(EncodingError::MixedMetadata { index });
868 }
869
870 T::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
871}
872
873#[cfg(test)]
874fn fixed_size_binary<const N: usize>(values: Vec<&[u8; N]>) -> FixedSizeBinaryArray {
875 FixedSizeBinaryArray::try_from_iter(values.into_iter()).unwrap()
876}
877
878#[cfg(test)]
879mod tests {
880 use nautilus_model::{
881 data::{
882 Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDepth10, QuoteTick,
883 depth::DEPTH10_LEN,
884 },
885 enums::{AggregationSource, BarAggregation, BookAction, OrderSide, PriceType},
886 identifiers::InstrumentId,
887 types::{PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity},
888 };
889 use rstest::rstest;
890
891 use super::*;
892
893 #[rstest]
894 fn test_record_batch_byte_encoders_reject_empty_data() {
895 let results = [
896 book_deltas_to_arrow_record_batch_bytes(&[]),
897 book_depth10_to_arrow_record_batch_bytes(&[]),
898 quotes_to_arrow_record_batch_bytes(&[]),
899 trades_to_arrow_record_batch_bytes(&[]),
900 bars_to_arrow_record_batch_bytes(&[]),
901 mark_prices_to_arrow_record_batch_bytes(&[]),
902 index_prices_to_arrow_record_batch_bytes(&[]),
903 instrument_status_to_arrow_record_batch_bytes(&[]),
904 option_greeks_to_arrow_record_batch_bytes(&[]),
905 instrument_closes_to_arrow_record_batch_bytes(&[]),
906 ];
907
908 for result in results {
909 assert!(matches!(result, Err(EncodingError::EmptyData)));
910 }
911 }
912
913 #[rstest]
914 fn test_validate_precision_bytes_rejects_wrong_width() {
915 let array = fixed_size_binary::<1>(vec![&[0]]);
916
917 let error = validate_precision_bytes(&array, "price").unwrap_err();
918
919 let EncodingError::PrecisionMismatch {
920 field,
921 expected_bytes,
922 actual_bytes,
923 } = error
924 else {
925 panic!("unexpected error variant: {error:?}");
926 };
927 assert_eq!(field, "price");
928 assert_eq!(expected_bytes, PRECISION_BYTES);
929 assert_eq!(actual_bytes, 1);
930 }
931
932 #[rstest]
933 fn test_quotes_to_arrow_record_batch_rejects_mixed_instruments() {
934 let first = QuoteTick::new(
935 InstrumentId::from("AAPL.XNAS"),
936 Price::from("100.01"),
937 Price::from("100.02"),
938 Quantity::from("10"),
939 Quantity::from("11"),
940 1.into(),
941 1.into(),
942 );
943 let second = QuoteTick::new(
944 InstrumentId::from("MSFT.XNAS"),
945 Price::from("200.01"),
946 Price::from("200.02"),
947 Quantity::from("20"),
948 Quantity::from("21"),
949 2.into(),
950 2.into(),
951 );
952
953 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
954
955 assert!(matches!(
956 result,
957 Err(EncodingError::MixedMetadata { index: 1 })
958 ));
959 }
960
961 #[rstest]
962 fn test_quotes_to_arrow_record_batch_rejects_mixed_precision() {
963 let instrument_id = InstrumentId::from("AAPL.XNAS");
964 let first = QuoteTick::new(
965 instrument_id,
966 Price::from("100.01"),
967 Price::from("100.02"),
968 Quantity::from("10.00"),
969 Quantity::from("11.00"),
970 1.into(),
971 1.into(),
972 );
973 let second = QuoteTick::new(
974 instrument_id,
975 Price::from("100.010"),
976 Price::from("100.020"),
977 Quantity::from("10.000"),
978 Quantity::from("11.000"),
979 2.into(),
980 2.into(),
981 );
982
983 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
984
985 assert!(matches!(
986 result,
987 Err(EncodingError::MixedMetadata { index: 1 })
988 ));
989 }
990
991 #[rstest]
992 fn test_bars_to_arrow_record_batch_rejects_mixed_bar_types() {
993 let instrument_id = InstrumentId::from("AAPL.XNAS");
994 let first_type = BarType::new(
995 instrument_id,
996 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
997 AggregationSource::Internal,
998 );
999 let second_type = BarType::new(
1000 instrument_id,
1001 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
1002 AggregationSource::Internal,
1003 );
1004 let first = Bar::new(
1005 first_type,
1006 Price::from("100.01"),
1007 Price::from("100.02"),
1008 Price::from("100.00"),
1009 Price::from("100.01"),
1010 Quantity::from("10"),
1011 1.into(),
1012 1.into(),
1013 );
1014 let second = Bar::new(
1015 second_type,
1016 Price::from("100.01"),
1017 Price::from("100.02"),
1018 Price::from("100.00"),
1019 Price::from("100.01"),
1020 Quantity::from("11"),
1021 2.into(),
1022 2.into(),
1023 );
1024
1025 let result = bars_to_arrow_record_batch_bytes(&[first, second]);
1026
1027 assert!(matches!(
1028 result,
1029 Err(EncodingError::MixedMetadata { index: 1 })
1030 ));
1031 }
1032
1033 #[rstest]
1034 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_price_precision() {
1035 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1036 let bid = BookOrder::new(
1037 OrderSide::Buy,
1038 Price::from("1.23"),
1039 Quantity::from("100.00"),
1040 1,
1041 );
1042 let ask = BookOrder::new(
1043 OrderSide::Sell,
1044 Price::from("1.24"),
1045 Quantity::from("100.00"),
1046 2,
1047 );
1048 let mut asks = [ask; DEPTH10_LEN];
1049 asks[1].price = Price::from("1.241");
1050 let depth = OrderBookDepth10::new(
1051 instrument_id,
1052 [bid; DEPTH10_LEN],
1053 asks,
1054 [1; DEPTH10_LEN],
1055 [1; DEPTH10_LEN],
1056 0,
1057 1,
1058 1.into(),
1059 1.into(),
1060 );
1061
1062 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1063
1064 assert!(matches!(
1065 result,
1066 Err(EncodingError::MixedMetadata { index: 0 })
1067 ));
1068 }
1069
1070 #[rstest]
1071 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_size_precision() {
1072 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1073 let bid = BookOrder::new(
1074 OrderSide::Buy,
1075 Price::from("1.23"),
1076 Quantity::from("100.00"),
1077 1,
1078 );
1079 let ask = BookOrder::new(
1080 OrderSide::Sell,
1081 Price::from("1.24"),
1082 Quantity::from("100.00"),
1083 2,
1084 );
1085 let mut bids = [bid; DEPTH10_LEN];
1086 bids[1].size = Quantity::from("100.000");
1087 let depth = OrderBookDepth10::new(
1088 instrument_id,
1089 bids,
1090 [ask; DEPTH10_LEN],
1091 [1; DEPTH10_LEN],
1092 [1; DEPTH10_LEN],
1093 0,
1094 1,
1095 1.into(),
1096 1.into(),
1097 );
1098
1099 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1100
1101 assert!(matches!(
1102 result,
1103 Err(EncodingError::MixedMetadata { index: 0 })
1104 ));
1105 }
1106
1107 #[rstest]
1108 fn test_depth10_to_arrow_record_batch_uses_first_defined_level_precision() {
1109 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1110 let bid = BookOrder::new(
1111 OrderSide::Buy,
1112 Price::from("1.23"),
1113 Quantity::from("100.00"),
1114 1,
1115 );
1116 let ask = BookOrder::new(
1117 OrderSide::Sell,
1118 Price::from("1.24"),
1119 Quantity::from("100.00"),
1120 2,
1121 );
1122 let mut bids = [bid; DEPTH10_LEN];
1123 bids[0].price = Price::from_raw(PRICE_UNDEF, 0);
1124 bids[0].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1125 let depth = OrderBookDepth10::new(
1126 instrument_id,
1127 bids,
1128 [ask; DEPTH10_LEN],
1129 [0; DEPTH10_LEN],
1130 [1; DEPTH10_LEN],
1131 0,
1132 1,
1133 1.into(),
1134 1.into(),
1135 );
1136
1137 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]).unwrap();
1138
1139 assert_eq!(
1140 result.schema().metadata().get(KEY_PRICE_PRECISION).unwrap(),
1141 "2"
1142 );
1143 assert_eq!(
1144 result.schema().metadata().get(KEY_SIZE_PRECISION).unwrap(),
1145 "2"
1146 );
1147 }
1148
1149 #[rstest]
1150 #[case::price(true)]
1151 #[case::size(false)]
1152 fn test_depth10_to_arrow_record_batch_rejects_partial_undefined_level(
1153 #[case] price_undefined: bool,
1154 ) {
1155 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1156 let bid = BookOrder::new(
1157 OrderSide::Buy,
1158 Price::from("1.23"),
1159 Quantity::from("100.00"),
1160 1,
1161 );
1162 let ask = BookOrder::new(
1163 OrderSide::Sell,
1164 Price::from("1.24"),
1165 Quantity::from("100.00"),
1166 2,
1167 );
1168 let mut asks = [ask; DEPTH10_LEN];
1169 if price_undefined {
1170 asks[1].price = Price::from_raw(PRICE_UNDEF, 0);
1171 } else {
1172 asks[1].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1173 }
1174 let depth = OrderBookDepth10::new(
1175 instrument_id,
1176 [bid; DEPTH10_LEN],
1177 asks,
1178 [1; DEPTH10_LEN],
1179 [1; DEPTH10_LEN],
1180 0,
1181 1,
1182 1.into(),
1183 1.into(),
1184 );
1185
1186 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1187
1188 assert!(matches!(
1189 result,
1190 Err(EncodingError::MixedMetadata { index: 0 })
1191 ));
1192 }
1193
1194 #[rstest]
1195 fn test_deltas_to_arrow_record_batch_skips_leading_clears_for_precision() {
1196 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1197 let first = OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into());
1198 let second = OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into());
1199 let third = OrderBookDelta::new(
1200 instrument_id,
1201 BookAction::Add,
1202 BookOrder::new(
1203 OrderSide::Buy,
1204 Price::from("1.23"),
1205 Quantity::from("100.000000"),
1206 1,
1207 ),
1208 0,
1209 2,
1210 3.into(),
1211 3.into(),
1212 );
1213 let expected = vec![first, second, third];
1214
1215 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1216 let metadata = batch.schema().metadata().clone();
1217 assert_eq!(
1218 metadata.get(KEY_PRICE_PRECISION).map(String::as_str),
1219 Some("2")
1220 );
1221 assert_eq!(
1222 metadata.get(KEY_SIZE_PRECISION).map(String::as_str),
1223 Some("6")
1224 );
1225
1226 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1227
1228 assert_eq!(decoded, expected);
1229 assert_eq!(decoded[2].order.price.precision, 2);
1230 assert_eq!(decoded[2].order.size.precision, 6);
1231 }
1232
1233 #[rstest]
1234 fn test_deltas_to_arrow_record_batch_all_clear_roundtrip() {
1235 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1236 let expected = vec![
1237 OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into()),
1238 OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into()),
1239 ];
1240
1241 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1242 let metadata = batch.schema().metadata().clone();
1243 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1244
1245 assert_eq!(decoded, expected);
1246 }
1247
1248 #[rstest]
1249 fn test_deltas_to_arrow_record_batch_rejects_mixed_precision() {
1250 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1251 let first = OrderBookDelta::new(
1252 instrument_id,
1253 BookAction::Add,
1254 BookOrder::new(
1255 OrderSide::Buy,
1256 Price::from("1.23"),
1257 Quantity::from("100.00"),
1258 1,
1259 ),
1260 0,
1261 1,
1262 1.into(),
1263 1.into(),
1264 );
1265 let second = OrderBookDelta::new(
1266 instrument_id,
1267 BookAction::Update,
1268 BookOrder::new(
1269 OrderSide::Buy,
1270 Price::from("1.234"),
1271 Quantity::from("100.000"),
1272 1,
1273 ),
1274 0,
1275 2,
1276 2.into(),
1277 2.into(),
1278 );
1279
1280 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1281
1282 assert!(matches!(
1283 result,
1284 Err(EncodingError::MixedMetadata { index: 1 })
1285 ));
1286 }
1287
1288 #[rstest]
1289 fn test_deltas_to_arrow_record_batch_rejects_mixed_instruments() {
1290 let first = OrderBookDelta::clear(InstrumentId::from("AUD/USD.SIM"), 0, 1.into(), 1.into());
1291 let second = OrderBookDelta::new(
1292 InstrumentId::from("EUR/USD.SIM"),
1293 BookAction::Add,
1294 BookOrder::new(
1295 OrderSide::Buy,
1296 Price::from("1.23"),
1297 Quantity::from("100.00"),
1298 1,
1299 ),
1300 0,
1301 1,
1302 2.into(),
1303 2.into(),
1304 );
1305
1306 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1307
1308 assert!(matches!(
1310 result,
1311 Err(EncodingError::MixedMetadata { index: 0 })
1312 ));
1313 }
1314}