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 = "display")]
39pub mod display;
40
41use std::{
42 collections::HashMap,
43 io::{self, Write},
44};
45
46use arrow::{
47 array::{
48 Array, ArrayRef, BinaryArray, BinaryViewArray, FixedSizeBinaryArray, StringArray,
49 StringViewArray,
50 },
51 datatypes::{DataType, Schema},
52 error::ArrowError,
53 ipc::writer::StreamWriter,
54 record_batch::RecordBatch,
55};
56use nautilus_model::{
57 data::{
58 Data, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, bar::Bar,
59 close::InstrumentClose, delta::OrderBookDelta, depth::OrderBookDepth10,
60 option_chain::OptionGreeks, quote::QuoteTick, trade::TradeTick,
61 },
62 enums::BookAction,
63 types::{
64 PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity,
65 fixed::{PRECISION_BYTES, correct_price_raw, correct_quantity_raw},
66 price::PriceRaw,
67 quantity::QuantityRaw,
68 },
69};
70#[cfg(feature = "python")]
71use pyo3::prelude::*;
72use ustr::Ustr;
73
74const KEY_BAR_TYPE: &str = "bar_type";
76pub const KEY_INSTRUMENT_ID: &str = "instrument_id";
77pub const KEY_PRICE_PRECISION: &str = "price_precision";
78pub const KEY_SIZE_PRECISION: &str = "size_precision";
79
80#[derive(thiserror::Error, Debug)]
81pub enum DataStreamingError {
82 #[error("I/O error: {0}")]
83 IoError(#[from] io::Error),
84 #[error("Arrow error: {0}")]
85 ArrowError(#[from] arrow::error::ArrowError),
86 #[cfg(feature = "python")]
87 #[error("Python error: {0}")]
88 PythonError(#[from] PyErr),
89}
90
91#[derive(thiserror::Error, Debug)]
92pub enum EncodingError {
93 #[error("Empty data")]
94 EmptyData,
95 #[error(
96 "Mixed metadata at row {index}; encode each instrument, bar type, or precision separately"
97 )]
98 MixedMetadata { index: usize },
99 #[error("Missing metadata key: `{0}`")]
100 MissingMetadata(&'static str),
101 #[error("Missing data column: `{0}` at index {1}")]
102 MissingColumn(&'static str, usize),
103 #[error("Error parsing `{0}`: {1}")]
104 ParseError(&'static str, String),
105 #[error("Invalid column type `{0}` at index {1}: expected {2}, found {3}")]
106 InvalidColumnType(&'static str, usize, DataType, DataType),
107 #[error(
108 "Precision mode mismatch for `{field}`: catalog data has {actual_bytes} byte values, \
109 but this build expects {expected_bytes} bytes. The catalog was created with a different \
110 precision mode (standard=8 bytes, high=16 bytes). Rebuild the catalog or change your \
111 build's precision mode. See: https://nautilustrader.io/docs/latest/getting_started/installation#precision-mode"
112 )]
113 PrecisionMismatch {
114 field: &'static str,
115 expected_bytes: i32,
116 actual_bytes: i32,
117 },
118 #[error("Arrow error: {0}")]
119 ArrowError(#[from] arrow::error::ArrowError),
120}
121
122#[inline]
123fn get_raw_price(bytes: &[u8]) -> PriceRaw {
124 PriceRaw::from_le_bytes(
125 bytes
126 .try_into()
127 .expect("Price raw bytes must be exactly the size of PriceRaw"),
128 )
129}
130
131#[inline]
132fn get_raw_quantity(bytes: &[u8]) -> QuantityRaw {
133 QuantityRaw::from_le_bytes(
134 bytes
135 .try_into()
136 .expect("Quantity raw bytes must be exactly the size of QuantityRaw"),
137 )
138}
139
140#[inline]
148fn get_corrected_raw_price(bytes: &[u8], precision: u8) -> PriceRaw {
149 let raw = get_raw_price(bytes);
150
151 if raw == PRICE_UNDEF || raw == PRICE_ERROR {
153 return raw;
154 }
155
156 correct_price_raw(raw, precision)
157}
158
159#[inline]
167fn get_corrected_raw_quantity(bytes: &[u8], precision: u8) -> QuantityRaw {
168 let raw = get_raw_quantity(bytes);
169
170 if raw == QUANTITY_UNDEF {
172 return raw;
173 }
174
175 correct_quantity_raw(raw, precision)
176}
177
178pub fn decode_price(
187 bytes: &[u8],
188 precision: u8,
189 field: &'static str,
190 row: usize,
191) -> Result<Price, EncodingError> {
192 let raw = get_corrected_raw_price(bytes, precision);
193 Price::from_raw_checked(raw, precision)
194 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
195}
196
197pub fn decode_quantity(
206 bytes: &[u8],
207 precision: u8,
208 field: &'static str,
209 row: usize,
210) -> Result<Quantity, EncodingError> {
211 let raw = get_corrected_raw_quantity(bytes, precision);
212 Quantity::from_raw_checked(raw, precision)
213 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
214}
215
216pub fn decode_price_with_sentinel(
224 bytes: &[u8],
225 precision: u8,
226 field: &'static str,
227 row: usize,
228) -> Result<Price, EncodingError> {
229 let raw = get_raw_price(bytes);
230 let (final_raw, final_precision) = if raw == PRICE_UNDEF {
231 (raw, 0)
232 } else {
233 (get_corrected_raw_price(bytes, precision), precision)
234 };
235 Price::from_raw_checked(final_raw, final_precision)
236 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
237}
238
239pub fn decode_quantity_with_sentinel(
247 bytes: &[u8],
248 precision: u8,
249 field: &'static str,
250 row: usize,
251) -> Result<Quantity, EncodingError> {
252 let raw = get_raw_quantity(bytes);
253 let (final_raw, final_precision) = if raw == QUANTITY_UNDEF {
254 (raw, 0)
255 } else {
256 (get_corrected_raw_quantity(bytes, precision), precision)
257 };
258 Quantity::from_raw_checked(final_raw, final_precision)
259 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
260}
261
262pub trait ArrowSchemaProvider {
264 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema;
266
267 #[must_use]
269 fn get_schema_map() -> HashMap<String, String> {
270 let schema = Self::get_schema(None);
271 let mut map = HashMap::new();
272
273 for field in schema.fields() {
274 let name = field.name().clone();
275 let data_type = format!("{:?}", field.data_type());
276 map.insert(name, data_type);
277 }
278 map
279 }
280}
281
282pub trait EncodeToRecordBatch
284where
285 Self: Sized + ArrowSchemaProvider,
286{
287 fn encode_batch(
293 metadata: &HashMap<String, String>,
294 data: &[Self],
295 ) -> Result<RecordBatch, ArrowError>;
296
297 fn metadata(&self) -> HashMap<String, String>;
299
300 fn chunk_metadata(chunk: &[Self]) -> HashMap<String, String> {
309 chunk
310 .first()
311 .map(Self::metadata)
312 .expect("Chunk must have at least one element to encode")
313 }
314}
315
316pub trait DecodeFromRecordBatch
318where
319 Self: Sized + Into<Data> + ArrowSchemaProvider,
320{
321 fn decode_batch(
327 metadata: &HashMap<String, String>,
328 record_batch: RecordBatch,
329 ) -> Result<Vec<Self>, EncodingError>;
330}
331
332pub trait DecodeTypedFromRecordBatch
334where
335 Self: Sized + ArrowSchemaProvider,
336{
337 fn decode_typed_batch(
343 metadata: &HashMap<String, String>,
344 record_batch: RecordBatch,
345 ) -> Result<Vec<Self>, EncodingError>;
346}
347
348impl<T> DecodeTypedFromRecordBatch for T
349where
350 T: DecodeFromRecordBatch,
351{
352 fn decode_typed_batch(
353 metadata: &HashMap<String, String>,
354 record_batch: RecordBatch,
355 ) -> Result<Vec<Self>, EncodingError> {
356 Self::decode_batch(metadata, record_batch)
357 }
358}
359
360pub trait DecodeDataFromRecordBatch
362where
363 Self: Sized + ArrowSchemaProvider,
364{
365 fn decode_data_batch(
371 metadata: &HashMap<String, String>,
372 record_batch: RecordBatch,
373 ) -> Result<Vec<Data>, EncodingError>;
374}
375
376pub trait WriteStream {
378 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError>;
384}
385
386impl<T: Write> WriteStream for T {
387 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError> {
388 let mut writer = StreamWriter::try_new(self, &record_batch.schema())?;
389 writer.write(record_batch)?;
390 writer.finish()?;
391 Ok(())
392 }
393}
394
395pub fn extract_column_string<'a>(
404 cols: &'a [ArrayRef],
405 column_key: &'static str,
406 column_index: usize,
407) -> Result<StringColumnRef<'a>, EncodingError> {
408 let column_values = cols
409 .get(column_index)
410 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
411 let dt = column_values.data_type();
412 if let Some(arr) = column_values.as_any().downcast_ref::<StringArray>() {
413 Ok(StringColumnRef::Utf8(arr))
414 } else if let Some(arr) = column_values.as_any().downcast_ref::<StringViewArray>() {
415 Ok(StringColumnRef::Utf8View(arr))
416 } else {
417 Err(EncodingError::InvalidColumnType(
418 column_key,
419 column_index,
420 DataType::Utf8,
421 dt.clone(),
422 ))
423 }
424}
425
426#[derive(Debug)]
428pub enum StringColumnRef<'a> {
429 Utf8(&'a StringArray),
430 Utf8View(&'a StringViewArray),
431}
432
433impl StringColumnRef<'_> {
434 #[inline]
436 #[must_use]
437 pub fn value(&self, i: usize) -> &str {
438 match self {
439 Self::Utf8(arr) => arr.value(i),
440 Self::Utf8View(arr) => arr.value(i),
441 }
442 }
443}
444
445pub fn extract_column_binary<'a>(
455 cols: &'a [ArrayRef],
456 column_key: &'static str,
457 column_index: usize,
458) -> Result<BinaryColumnRef<'a>, EncodingError> {
459 let column_values = cols
460 .get(column_index)
461 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
462 let dt = column_values.data_type();
463 if let Some(arr) = column_values.as_any().downcast_ref::<BinaryArray>() {
464 Ok(BinaryColumnRef::Binary(arr))
465 } else if let Some(arr) = column_values.as_any().downcast_ref::<BinaryViewArray>() {
466 Ok(BinaryColumnRef::BinaryView(arr))
467 } else {
468 Err(EncodingError::InvalidColumnType(
469 column_key,
470 column_index,
471 DataType::Binary,
472 dt.clone(),
473 ))
474 }
475}
476
477#[derive(Debug)]
479pub enum BinaryColumnRef<'a> {
480 Binary(&'a BinaryArray),
481 BinaryView(&'a BinaryViewArray),
482}
483
484impl BinaryColumnRef<'_> {
485 #[inline]
487 #[must_use]
488 pub fn value(&self, i: usize) -> &[u8] {
489 match self {
490 Self::Binary(arr) => arr.value(i),
491 Self::BinaryView(arr) => arr.value(i),
492 }
493 }
494}
495
496pub fn extract_column<'a, T: Array + 'static>(
504 cols: &'a [ArrayRef],
505 column_key: &'static str,
506 column_index: usize,
507 expected_type: DataType,
508) -> Result<&'a T, EncodingError> {
509 let column_values = cols
510 .get(column_index)
511 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
512 let downcasted_values =
513 column_values
514 .as_any()
515 .downcast_ref::<T>()
516 .ok_or(EncodingError::InvalidColumnType(
517 column_key,
518 column_index,
519 expected_type,
520 column_values.data_type().clone(),
521 ))?;
522 Ok(downcasted_values)
523}
524
525pub fn extract_column_by_name_or_index<'a, T: Array + 'static>(
531 record_batch: &'a RecordBatch,
532 column_key: &'static str,
533 fallback_index: usize,
534 expected_type: DataType,
535) -> Result<&'a T, EncodingError> {
536 let column_index = record_batch
537 .schema()
538 .index_of(column_key)
539 .unwrap_or(fallback_index);
540 extract_column::<T>(
541 record_batch.columns(),
542 column_key,
543 column_index,
544 expected_type,
545 )
546}
547
548pub fn extract_optional_string_column_by_name<'a>(
554 record_batch: &'a RecordBatch,
555 column_key: &'static str,
556) -> Result<Option<&'a StringArray>, EncodingError> {
557 let Ok(column_index) = record_batch.schema().index_of(column_key) else {
558 return Ok(None);
559 };
560 let column_values = record_batch
561 .columns()
562 .get(column_index)
563 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
564 let downcasted_values = column_values.as_any().downcast_ref::<StringArray>().ok_or(
565 EncodingError::InvalidColumnType(
566 column_key,
567 column_index,
568 DataType::Utf8,
569 column_values.data_type().clone(),
570 ),
571 )?;
572 Ok(Some(downcasted_values))
573}
574
575#[must_use]
577pub fn optional_ustr_value(values: Option<&StringArray>, row: usize) -> Option<Ustr> {
578 values.and_then(|column| (!column.is_null(row)).then(|| Ustr::from(column.value(row))))
579}
580
581pub fn validate_precision_bytes(
591 array: &FixedSizeBinaryArray,
592 field: &'static str,
593) -> Result<(), EncodingError> {
594 let actual = array.value_length();
595 if actual != PRECISION_BYTES {
596 return Err(EncodingError::PrecisionMismatch {
597 field,
598 expected_bytes: PRECISION_BYTES,
599 actual_bytes: actual,
600 });
601 }
602 Ok(())
603}
604
605pub fn book_deltas_to_arrow_record_batch_bytes(
615 data: &[OrderBookDelta],
616) -> Result<RecordBatch, EncodingError> {
617 let Some(first) = data.first() else {
618 return Err(EncodingError::EmptyData);
619 };
620
621 let metadata = OrderBookDelta::chunk_metadata(data);
622 let instrument_id = data
623 .iter()
624 .find(|delta| delta.action != BookAction::Clear)
625 .unwrap_or(first)
626 .instrument_id;
627
628 if let Some(index) = data.iter().position(|delta| {
629 delta.instrument_id != instrument_id
630 || (delta.action != BookAction::Clear && delta.metadata() != metadata)
631 }) {
632 return Err(EncodingError::MixedMetadata { index });
633 }
634
635 OrderBookDelta::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
636}
637
638pub fn book_depth10_to_arrow_record_batch_bytes(
647 data: &[OrderBookDepth10],
648) -> Result<RecordBatch, EncodingError> {
649 let Some(first) = data.first() else {
650 return Err(EncodingError::EmptyData);
651 };
652 let precision = data
653 .iter()
654 .flat_map(|depth| depth.bids.iter().chain(&depth.asks))
655 .find(|order| !order.price.is_undefined() && !order.size.is_undefined())
656 .map_or(
657 (first.bids[0].price.precision, first.bids[0].size.precision),
658 |order| (order.price.precision, order.size.precision),
659 );
660
661 if let Some(index) = data.iter().position(|depth| {
662 depth.instrument_id != first.instrument_id || !depth_precision_is_uniform(depth, precision)
663 }) {
664 return Err(EncodingError::MixedMetadata { index });
665 }
666
667 let metadata = OrderBookDepth10::get_metadata(&first.instrument_id, precision.0, precision.1);
668 OrderBookDepth10::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
669}
670
671fn depth_precision_is_uniform(depth: &OrderBookDepth10, precision: (u8, u8)) -> bool {
672 depth.bids.iter().chain(&depth.asks).all(|order| {
673 match (order.price.is_undefined(), order.size.is_undefined()) {
674 (true, true) => true,
675 (false, false) => {
676 order.price.precision == precision.0 && order.size.precision == precision.1
677 }
678 _ => false,
679 }
680 })
681}
682
683pub fn quotes_to_arrow_record_batch_bytes(
692 data: &[QuoteTick],
693) -> Result<RecordBatch, EncodingError> {
694 encode_batch_with_metadata(data)
695}
696
697pub fn trades_to_arrow_record_batch_bytes(
706 data: &[TradeTick],
707) -> Result<RecordBatch, EncodingError> {
708 encode_batch_with_metadata(data)
709}
710
711pub fn bars_to_arrow_record_batch_bytes(data: &[Bar]) -> Result<RecordBatch, EncodingError> {
720 encode_batch_with_metadata(data)
721}
722
723pub fn mark_prices_to_arrow_record_batch_bytes(
732 data: &[MarkPriceUpdate],
733) -> Result<RecordBatch, EncodingError> {
734 encode_batch_with_metadata(data)
735}
736
737pub fn index_prices_to_arrow_record_batch_bytes(
746 data: &[IndexPriceUpdate],
747) -> Result<RecordBatch, EncodingError> {
748 encode_batch_with_metadata(data)
749}
750
751#[expect(clippy::missing_panics_doc)] pub fn instrument_status_to_arrow_record_batch_bytes(
760 data: &[InstrumentStatus],
761) -> Result<RecordBatch, EncodingError> {
762 if data.is_empty() {
763 return Err(EncodingError::EmptyData);
764 }
765
766 let first = data.first().unwrap();
767 let metadata = first.metadata();
768 InstrumentStatus::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
769}
770
771#[expect(clippy::missing_panics_doc)] pub fn option_greeks_to_arrow_record_batch_bytes(
780 data: &[OptionGreeks],
781) -> Result<RecordBatch, EncodingError> {
782 if data.is_empty() {
783 return Err(EncodingError::EmptyData);
784 }
785
786 let first = data.first().unwrap();
787 let metadata = first.metadata();
788 OptionGreeks::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
789}
790
791pub fn instrument_closes_to_arrow_record_batch_bytes(
800 data: &[InstrumentClose],
801) -> Result<RecordBatch, EncodingError> {
802 encode_batch_with_metadata(data)
803}
804
805fn encode_batch_with_metadata<T>(data: &[T]) -> Result<RecordBatch, EncodingError>
806where
807 T: EncodeToRecordBatch,
808{
809 if data.is_empty() {
810 return Err(EncodingError::EmptyData);
811 }
812
813 let metadata = T::chunk_metadata(data);
814 if let Some(index) = data.iter().position(|value| value.metadata() != metadata) {
815 return Err(EncodingError::MixedMetadata { index });
816 }
817
818 T::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
819}
820
821#[cfg(test)]
822fn fixed_size_binary<const N: usize>(values: Vec<&[u8; N]>) -> FixedSizeBinaryArray {
823 FixedSizeBinaryArray::try_from_iter(values.into_iter()).unwrap()
824}
825
826#[cfg(test)]
827mod tests {
828 use nautilus_model::{
829 data::{
830 Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDepth10, QuoteTick,
831 depth::DEPTH10_LEN,
832 },
833 enums::{AggregationSource, BarAggregation, BookAction, OrderSide, PriceType},
834 identifiers::InstrumentId,
835 types::{PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity},
836 };
837 use rstest::rstest;
838
839 use super::*;
840
841 #[rstest]
842 fn test_quotes_to_arrow_record_batch_rejects_mixed_instruments() {
843 let first = QuoteTick::new(
844 InstrumentId::from("AAPL.XNAS"),
845 Price::from("100.01"),
846 Price::from("100.02"),
847 Quantity::from("10"),
848 Quantity::from("11"),
849 1.into(),
850 1.into(),
851 );
852 let second = QuoteTick::new(
853 InstrumentId::from("MSFT.XNAS"),
854 Price::from("200.01"),
855 Price::from("200.02"),
856 Quantity::from("20"),
857 Quantity::from("21"),
858 2.into(),
859 2.into(),
860 );
861
862 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
863
864 assert!(matches!(
865 result,
866 Err(EncodingError::MixedMetadata { index: 1 })
867 ));
868 }
869
870 #[rstest]
871 fn test_quotes_to_arrow_record_batch_rejects_mixed_precision() {
872 let instrument_id = InstrumentId::from("AAPL.XNAS");
873 let first = QuoteTick::new(
874 instrument_id,
875 Price::from("100.01"),
876 Price::from("100.02"),
877 Quantity::from("10.00"),
878 Quantity::from("11.00"),
879 1.into(),
880 1.into(),
881 );
882 let second = QuoteTick::new(
883 instrument_id,
884 Price::from("100.010"),
885 Price::from("100.020"),
886 Quantity::from("10.000"),
887 Quantity::from("11.000"),
888 2.into(),
889 2.into(),
890 );
891
892 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
893
894 assert!(matches!(
895 result,
896 Err(EncodingError::MixedMetadata { index: 1 })
897 ));
898 }
899
900 #[rstest]
901 fn test_bars_to_arrow_record_batch_rejects_mixed_bar_types() {
902 let instrument_id = InstrumentId::from("AAPL.XNAS");
903 let first_type = BarType::new(
904 instrument_id,
905 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
906 AggregationSource::Internal,
907 );
908 let second_type = BarType::new(
909 instrument_id,
910 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
911 AggregationSource::Internal,
912 );
913 let first = Bar::new(
914 first_type,
915 Price::from("100.01"),
916 Price::from("100.02"),
917 Price::from("100.00"),
918 Price::from("100.01"),
919 Quantity::from("10"),
920 1.into(),
921 1.into(),
922 );
923 let second = Bar::new(
924 second_type,
925 Price::from("100.01"),
926 Price::from("100.02"),
927 Price::from("100.00"),
928 Price::from("100.01"),
929 Quantity::from("11"),
930 2.into(),
931 2.into(),
932 );
933
934 let result = bars_to_arrow_record_batch_bytes(&[first, second]);
935
936 assert!(matches!(
937 result,
938 Err(EncodingError::MixedMetadata { index: 1 })
939 ));
940 }
941
942 #[rstest]
943 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_price_precision() {
944 let instrument_id = InstrumentId::from("AUD/USD.SIM");
945 let bid = BookOrder::new(
946 OrderSide::Buy,
947 Price::from("1.23"),
948 Quantity::from("100.00"),
949 1,
950 );
951 let ask = BookOrder::new(
952 OrderSide::Sell,
953 Price::from("1.24"),
954 Quantity::from("100.00"),
955 2,
956 );
957 let mut asks = [ask; DEPTH10_LEN];
958 asks[1].price = Price::from("1.241");
959 let depth = OrderBookDepth10::new(
960 instrument_id,
961 [bid; DEPTH10_LEN],
962 asks,
963 [1; DEPTH10_LEN],
964 [1; DEPTH10_LEN],
965 0,
966 1,
967 1.into(),
968 1.into(),
969 );
970
971 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
972
973 assert!(matches!(
974 result,
975 Err(EncodingError::MixedMetadata { index: 0 })
976 ));
977 }
978
979 #[rstest]
980 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_size_precision() {
981 let instrument_id = InstrumentId::from("AUD/USD.SIM");
982 let bid = BookOrder::new(
983 OrderSide::Buy,
984 Price::from("1.23"),
985 Quantity::from("100.00"),
986 1,
987 );
988 let ask = BookOrder::new(
989 OrderSide::Sell,
990 Price::from("1.24"),
991 Quantity::from("100.00"),
992 2,
993 );
994 let mut bids = [bid; DEPTH10_LEN];
995 bids[1].size = Quantity::from("100.000");
996 let depth = OrderBookDepth10::new(
997 instrument_id,
998 bids,
999 [ask; DEPTH10_LEN],
1000 [1; DEPTH10_LEN],
1001 [1; DEPTH10_LEN],
1002 0,
1003 1,
1004 1.into(),
1005 1.into(),
1006 );
1007
1008 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1009
1010 assert!(matches!(
1011 result,
1012 Err(EncodingError::MixedMetadata { index: 0 })
1013 ));
1014 }
1015
1016 #[rstest]
1017 fn test_depth10_to_arrow_record_batch_uses_first_defined_level_precision() {
1018 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1019 let bid = BookOrder::new(
1020 OrderSide::Buy,
1021 Price::from("1.23"),
1022 Quantity::from("100.00"),
1023 1,
1024 );
1025 let ask = BookOrder::new(
1026 OrderSide::Sell,
1027 Price::from("1.24"),
1028 Quantity::from("100.00"),
1029 2,
1030 );
1031 let mut bids = [bid; DEPTH10_LEN];
1032 bids[0].price = Price::from_raw(PRICE_UNDEF, 0);
1033 bids[0].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1034 let depth = OrderBookDepth10::new(
1035 instrument_id,
1036 bids,
1037 [ask; DEPTH10_LEN],
1038 [0; DEPTH10_LEN],
1039 [1; DEPTH10_LEN],
1040 0,
1041 1,
1042 1.into(),
1043 1.into(),
1044 );
1045
1046 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]).unwrap();
1047
1048 assert_eq!(
1049 result.schema().metadata().get(KEY_PRICE_PRECISION).unwrap(),
1050 "2"
1051 );
1052 assert_eq!(
1053 result.schema().metadata().get(KEY_SIZE_PRECISION).unwrap(),
1054 "2"
1055 );
1056 }
1057
1058 #[rstest]
1059 #[case::price(true)]
1060 #[case::size(false)]
1061 fn test_depth10_to_arrow_record_batch_rejects_partial_undefined_level(
1062 #[case] price_undefined: bool,
1063 ) {
1064 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1065 let bid = BookOrder::new(
1066 OrderSide::Buy,
1067 Price::from("1.23"),
1068 Quantity::from("100.00"),
1069 1,
1070 );
1071 let ask = BookOrder::new(
1072 OrderSide::Sell,
1073 Price::from("1.24"),
1074 Quantity::from("100.00"),
1075 2,
1076 );
1077 let mut asks = [ask; DEPTH10_LEN];
1078 if price_undefined {
1079 asks[1].price = Price::from_raw(PRICE_UNDEF, 0);
1080 } else {
1081 asks[1].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1082 }
1083 let depth = OrderBookDepth10::new(
1084 instrument_id,
1085 [bid; DEPTH10_LEN],
1086 asks,
1087 [1; DEPTH10_LEN],
1088 [1; DEPTH10_LEN],
1089 0,
1090 1,
1091 1.into(),
1092 1.into(),
1093 );
1094
1095 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1096
1097 assert!(matches!(
1098 result,
1099 Err(EncodingError::MixedMetadata { index: 0 })
1100 ));
1101 }
1102
1103 #[rstest]
1104 fn test_deltas_to_arrow_record_batch_skips_leading_clears_for_precision() {
1105 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1106 let first = OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into());
1107 let second = OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into());
1108 let third = OrderBookDelta::new(
1109 instrument_id,
1110 BookAction::Add,
1111 BookOrder::new(
1112 OrderSide::Buy,
1113 Price::from("1.23"),
1114 Quantity::from("100.000000"),
1115 1,
1116 ),
1117 0,
1118 2,
1119 3.into(),
1120 3.into(),
1121 );
1122 let expected = vec![first, second, third];
1123
1124 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1125 let metadata = batch.schema().metadata().clone();
1126 assert_eq!(
1127 metadata.get(KEY_PRICE_PRECISION).map(String::as_str),
1128 Some("2")
1129 );
1130 assert_eq!(
1131 metadata.get(KEY_SIZE_PRECISION).map(String::as_str),
1132 Some("6")
1133 );
1134
1135 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1136
1137 assert_eq!(decoded, expected);
1138 assert_eq!(decoded[2].order.price.precision, 2);
1139 assert_eq!(decoded[2].order.size.precision, 6);
1140 }
1141
1142 #[rstest]
1143 fn test_deltas_to_arrow_record_batch_all_clear_roundtrip() {
1144 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1145 let expected = vec![
1146 OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into()),
1147 OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into()),
1148 ];
1149
1150 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1151 let metadata = batch.schema().metadata().clone();
1152 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1153
1154 assert_eq!(decoded, expected);
1155 }
1156
1157 #[rstest]
1158 fn test_deltas_to_arrow_record_batch_rejects_mixed_precision() {
1159 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1160 let first = OrderBookDelta::new(
1161 instrument_id,
1162 BookAction::Add,
1163 BookOrder::new(
1164 OrderSide::Buy,
1165 Price::from("1.23"),
1166 Quantity::from("100.00"),
1167 1,
1168 ),
1169 0,
1170 1,
1171 1.into(),
1172 1.into(),
1173 );
1174 let second = OrderBookDelta::new(
1175 instrument_id,
1176 BookAction::Update,
1177 BookOrder::new(
1178 OrderSide::Buy,
1179 Price::from("1.234"),
1180 Quantity::from("100.000"),
1181 1,
1182 ),
1183 0,
1184 2,
1185 2.into(),
1186 2.into(),
1187 );
1188
1189 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1190
1191 assert!(matches!(
1192 result,
1193 Err(EncodingError::MixedMetadata { index: 1 })
1194 ));
1195 }
1196
1197 #[rstest]
1198 fn test_deltas_to_arrow_record_batch_rejects_mixed_instruments() {
1199 let first = OrderBookDelta::clear(InstrumentId::from("AUD/USD.SIM"), 0, 1.into(), 1.into());
1200 let second = OrderBookDelta::new(
1201 InstrumentId::from("EUR/USD.SIM"),
1202 BookAction::Add,
1203 BookOrder::new(
1204 OrderSide::Buy,
1205 Price::from("1.23"),
1206 Quantity::from("100.00"),
1207 1,
1208 ),
1209 0,
1210 1,
1211 2.into(),
1212 2.into(),
1213 );
1214
1215 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1216
1217 assert!(matches!(
1219 result,
1220 Err(EncodingError::MixedMetadata { index: 0 })
1221 ));
1222 }
1223}