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::{Array, ArrayRef, FixedSizeBinaryArray, StringArray, StringViewArray},
48 datatypes::{DataType, Schema},
49 error::ArrowError,
50 ipc::writer::StreamWriter,
51 record_batch::RecordBatch,
52};
53use nautilus_model::{
54 data::{
55 Data, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, bar::Bar,
56 close::InstrumentClose, delta::OrderBookDelta, depth::OrderBookDepth10,
57 option_chain::OptionGreeks, quote::QuoteTick, trade::TradeTick,
58 },
59 enums::BookAction,
60 types::{
61 PRICE_ERROR, PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity,
62 fixed::{PRECISION_BYTES, correct_price_raw, correct_quantity_raw},
63 price::PriceRaw,
64 quantity::QuantityRaw,
65 },
66};
67#[cfg(feature = "python")]
68use pyo3::prelude::*;
69use ustr::Ustr;
70
71const KEY_BAR_TYPE: &str = "bar_type";
73pub const KEY_INSTRUMENT_ID: &str = "instrument_id";
74pub const KEY_PRICE_PRECISION: &str = "price_precision";
75pub const KEY_SIZE_PRECISION: &str = "size_precision";
76
77#[derive(thiserror::Error, Debug)]
78pub enum DataStreamingError {
79 #[error("I/O error: {0}")]
80 IoError(#[from] io::Error),
81 #[error("Arrow error: {0}")]
82 ArrowError(#[from] arrow::error::ArrowError),
83 #[cfg(feature = "python")]
84 #[error("Python error: {0}")]
85 PythonError(#[from] PyErr),
86}
87
88#[derive(thiserror::Error, Debug)]
89pub enum EncodingError {
90 #[error("Empty data")]
91 EmptyData,
92 #[error(
93 "Mixed metadata at row {index}; encode each instrument, bar type, or precision separately"
94 )]
95 MixedMetadata { index: usize },
96 #[error("Missing metadata key: `{0}`")]
97 MissingMetadata(&'static str),
98 #[error("Missing data column: `{0}` at index {1}")]
99 MissingColumn(&'static str, usize),
100 #[error("Error parsing `{0}`: {1}")]
101 ParseError(&'static str, String),
102 #[error("Invalid column type `{0}` at index {1}: expected {2}, found {3}")]
103 InvalidColumnType(&'static str, usize, DataType, DataType),
104 #[error(
105 "Precision mode mismatch for `{field}`: catalog data has {actual_bytes} byte values, \
106 but this build expects {expected_bytes} bytes. The catalog was created with a different \
107 precision mode (standard=8 bytes, high=16 bytes). Rebuild the catalog or change your \
108 build's precision mode. See: https://nautilustrader.io/docs/latest/getting_started/installation#precision-mode"
109 )]
110 PrecisionMismatch {
111 field: &'static str,
112 expected_bytes: i32,
113 actual_bytes: i32,
114 },
115 #[error("Arrow error: {0}")]
116 ArrowError(#[from] arrow::error::ArrowError),
117}
118
119#[inline]
120fn get_raw_price(bytes: &[u8]) -> PriceRaw {
121 PriceRaw::from_le_bytes(
122 bytes
123 .try_into()
124 .expect("Price raw bytes must be exactly the size of PriceRaw"),
125 )
126}
127
128#[inline]
129fn get_raw_quantity(bytes: &[u8]) -> QuantityRaw {
130 QuantityRaw::from_le_bytes(
131 bytes
132 .try_into()
133 .expect("Quantity raw bytes must be exactly the size of QuantityRaw"),
134 )
135}
136
137#[inline]
145fn get_corrected_raw_price(bytes: &[u8], precision: u8) -> PriceRaw {
146 let raw = get_raw_price(bytes);
147
148 if raw == PRICE_UNDEF || raw == PRICE_ERROR {
150 return raw;
151 }
152
153 correct_price_raw(raw, precision)
154}
155
156#[inline]
164fn get_corrected_raw_quantity(bytes: &[u8], precision: u8) -> QuantityRaw {
165 let raw = get_raw_quantity(bytes);
166
167 if raw == QUANTITY_UNDEF {
169 return raw;
170 }
171
172 correct_quantity_raw(raw, precision)
173}
174
175pub fn decode_price(
184 bytes: &[u8],
185 precision: u8,
186 field: &'static str,
187 row: usize,
188) -> Result<Price, EncodingError> {
189 let raw = get_corrected_raw_price(bytes, precision);
190 Price::from_raw_checked(raw, precision)
191 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
192}
193
194pub fn decode_quantity(
203 bytes: &[u8],
204 precision: u8,
205 field: &'static str,
206 row: usize,
207) -> Result<Quantity, EncodingError> {
208 let raw = get_corrected_raw_quantity(bytes, precision);
209 Quantity::from_raw_checked(raw, precision)
210 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
211}
212
213pub fn decode_price_with_sentinel(
221 bytes: &[u8],
222 precision: u8,
223 field: &'static str,
224 row: usize,
225) -> Result<Price, EncodingError> {
226 let raw = get_raw_price(bytes);
227 let (final_raw, final_precision) = if raw == PRICE_UNDEF {
228 (raw, 0)
229 } else {
230 (get_corrected_raw_price(bytes, precision), precision)
231 };
232 Price::from_raw_checked(final_raw, final_precision)
233 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
234}
235
236pub fn decode_quantity_with_sentinel(
244 bytes: &[u8],
245 precision: u8,
246 field: &'static str,
247 row: usize,
248) -> Result<Quantity, EncodingError> {
249 let raw = get_raw_quantity(bytes);
250 let (final_raw, final_precision) = if raw == QUANTITY_UNDEF {
251 (raw, 0)
252 } else {
253 (get_corrected_raw_quantity(bytes, precision), precision)
254 };
255 Quantity::from_raw_checked(final_raw, final_precision)
256 .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
257}
258
259pub trait ArrowSchemaProvider {
261 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema;
263
264 #[must_use]
266 fn get_schema_map() -> HashMap<String, String> {
267 let schema = Self::get_schema(None);
268 let mut map = HashMap::new();
269
270 for field in schema.fields() {
271 let name = field.name().clone();
272 let data_type = format!("{:?}", field.data_type());
273 map.insert(name, data_type);
274 }
275 map
276 }
277}
278
279pub trait EncodeToRecordBatch
281where
282 Self: Sized + ArrowSchemaProvider,
283{
284 fn encode_batch(
290 metadata: &HashMap<String, String>,
291 data: &[Self],
292 ) -> Result<RecordBatch, ArrowError>;
293
294 fn metadata(&self) -> HashMap<String, String>;
296
297 fn chunk_metadata(chunk: &[Self]) -> HashMap<String, String> {
306 chunk
307 .first()
308 .map(Self::metadata)
309 .expect("Chunk must have at least one element to encode")
310 }
311}
312
313pub trait DecodeFromRecordBatch
315where
316 Self: Sized + Into<Data> + ArrowSchemaProvider,
317{
318 fn decode_batch(
324 metadata: &HashMap<String, String>,
325 record_batch: RecordBatch,
326 ) -> Result<Vec<Self>, EncodingError>;
327}
328
329pub trait DecodeTypedFromRecordBatch
331where
332 Self: Sized + ArrowSchemaProvider,
333{
334 fn decode_typed_batch(
340 metadata: &HashMap<String, String>,
341 record_batch: RecordBatch,
342 ) -> Result<Vec<Self>, EncodingError>;
343}
344
345impl<T> DecodeTypedFromRecordBatch for T
346where
347 T: DecodeFromRecordBatch,
348{
349 fn decode_typed_batch(
350 metadata: &HashMap<String, String>,
351 record_batch: RecordBatch,
352 ) -> Result<Vec<Self>, EncodingError> {
353 Self::decode_batch(metadata, record_batch)
354 }
355}
356
357pub trait DecodeDataFromRecordBatch
359where
360 Self: Sized + ArrowSchemaProvider,
361{
362 fn decode_data_batch(
368 metadata: &HashMap<String, String>,
369 record_batch: RecordBatch,
370 ) -> Result<Vec<Data>, EncodingError>;
371}
372
373pub trait WriteStream {
375 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError>;
381}
382
383impl<T: Write> WriteStream for T {
384 fn write(&mut self, record_batch: &RecordBatch) -> Result<(), DataStreamingError> {
385 let mut writer = StreamWriter::try_new(self, &record_batch.schema())?;
386 writer.write(record_batch)?;
387 writer.finish()?;
388 Ok(())
389 }
390}
391
392pub fn extract_column_string<'a>(
401 cols: &'a [ArrayRef],
402 column_key: &'static str,
403 column_index: usize,
404) -> Result<StringColumnRef<'a>, EncodingError> {
405 let column_values = cols
406 .get(column_index)
407 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
408 let dt = column_values.data_type();
409 if let Some(arr) = column_values.as_any().downcast_ref::<StringArray>() {
410 Ok(StringColumnRef::Utf8(arr))
411 } else if let Some(arr) = column_values.as_any().downcast_ref::<StringViewArray>() {
412 Ok(StringColumnRef::Utf8View(arr))
413 } else {
414 Err(EncodingError::InvalidColumnType(
415 column_key,
416 column_index,
417 DataType::Utf8,
418 dt.clone(),
419 ))
420 }
421}
422
423#[derive(Debug)]
425pub enum StringColumnRef<'a> {
426 Utf8(&'a StringArray),
427 Utf8View(&'a StringViewArray),
428}
429
430impl StringColumnRef<'_> {
431 #[inline]
433 #[must_use]
434 pub fn value(&self, i: usize) -> &str {
435 match self {
436 Self::Utf8(arr) => arr.value(i),
437 Self::Utf8View(arr) => arr.value(i),
438 }
439 }
440}
441
442pub fn extract_column<'a, T: Array + 'static>(
450 cols: &'a [ArrayRef],
451 column_key: &'static str,
452 column_index: usize,
453 expected_type: DataType,
454) -> Result<&'a T, EncodingError> {
455 let column_values = cols
456 .get(column_index)
457 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
458 let downcasted_values =
459 column_values
460 .as_any()
461 .downcast_ref::<T>()
462 .ok_or(EncodingError::InvalidColumnType(
463 column_key,
464 column_index,
465 expected_type,
466 column_values.data_type().clone(),
467 ))?;
468 Ok(downcasted_values)
469}
470
471pub fn extract_column_by_name_or_index<'a, T: Array + 'static>(
477 record_batch: &'a RecordBatch,
478 column_key: &'static str,
479 fallback_index: usize,
480 expected_type: DataType,
481) -> Result<&'a T, EncodingError> {
482 let column_index = record_batch
483 .schema()
484 .index_of(column_key)
485 .unwrap_or(fallback_index);
486 extract_column::<T>(
487 record_batch.columns(),
488 column_key,
489 column_index,
490 expected_type,
491 )
492}
493
494pub fn extract_optional_string_column_by_name<'a>(
500 record_batch: &'a RecordBatch,
501 column_key: &'static str,
502) -> Result<Option<&'a StringArray>, EncodingError> {
503 let Ok(column_index) = record_batch.schema().index_of(column_key) else {
504 return Ok(None);
505 };
506 let column_values = record_batch
507 .columns()
508 .get(column_index)
509 .ok_or(EncodingError::MissingColumn(column_key, column_index))?;
510 let downcasted_values = column_values.as_any().downcast_ref::<StringArray>().ok_or(
511 EncodingError::InvalidColumnType(
512 column_key,
513 column_index,
514 DataType::Utf8,
515 column_values.data_type().clone(),
516 ),
517 )?;
518 Ok(Some(downcasted_values))
519}
520
521#[must_use]
523pub fn optional_ustr_value(values: Option<&StringArray>, row: usize) -> Option<Ustr> {
524 values.and_then(|column| (!column.is_null(row)).then(|| Ustr::from(column.value(row))))
525}
526
527pub fn validate_precision_bytes(
537 array: &FixedSizeBinaryArray,
538 field: &'static str,
539) -> Result<(), EncodingError> {
540 let actual = array.value_length();
541 if actual != PRECISION_BYTES {
542 return Err(EncodingError::PrecisionMismatch {
543 field,
544 expected_bytes: PRECISION_BYTES,
545 actual_bytes: actual,
546 });
547 }
548 Ok(())
549}
550
551pub fn book_deltas_to_arrow_record_batch_bytes(
561 data: &[OrderBookDelta],
562) -> Result<RecordBatch, EncodingError> {
563 let Some(first) = data.first() else {
564 return Err(EncodingError::EmptyData);
565 };
566
567 let metadata = OrderBookDelta::chunk_metadata(data);
568 let instrument_id = data
569 .iter()
570 .find(|delta| delta.action != BookAction::Clear)
571 .unwrap_or(first)
572 .instrument_id;
573
574 if let Some(index) = data.iter().position(|delta| {
575 delta.instrument_id != instrument_id
576 || (delta.action != BookAction::Clear && delta.metadata() != metadata)
577 }) {
578 return Err(EncodingError::MixedMetadata { index });
579 }
580
581 OrderBookDelta::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
582}
583
584pub fn book_depth10_to_arrow_record_batch_bytes(
593 data: &[OrderBookDepth10],
594) -> Result<RecordBatch, EncodingError> {
595 let Some(first) = data.first() else {
596 return Err(EncodingError::EmptyData);
597 };
598 let precision = data
599 .iter()
600 .flat_map(|depth| depth.bids.iter().chain(&depth.asks))
601 .find(|order| !order.price.is_undefined() && !order.size.is_undefined())
602 .map_or(
603 (first.bids[0].price.precision, first.bids[0].size.precision),
604 |order| (order.price.precision, order.size.precision),
605 );
606
607 if let Some(index) = data.iter().position(|depth| {
608 depth.instrument_id != first.instrument_id || !depth_precision_is_uniform(depth, precision)
609 }) {
610 return Err(EncodingError::MixedMetadata { index });
611 }
612
613 let metadata = OrderBookDepth10::get_metadata(&first.instrument_id, precision.0, precision.1);
614 OrderBookDepth10::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
615}
616
617fn depth_precision_is_uniform(depth: &OrderBookDepth10, precision: (u8, u8)) -> bool {
618 depth.bids.iter().chain(&depth.asks).all(|order| {
619 match (order.price.is_undefined(), order.size.is_undefined()) {
620 (true, true) => true,
621 (false, false) => {
622 order.price.precision == precision.0 && order.size.precision == precision.1
623 }
624 _ => false,
625 }
626 })
627}
628
629pub fn quotes_to_arrow_record_batch_bytes(
638 data: &[QuoteTick],
639) -> Result<RecordBatch, EncodingError> {
640 encode_batch_with_metadata(data)
641}
642
643pub fn trades_to_arrow_record_batch_bytes(
652 data: &[TradeTick],
653) -> Result<RecordBatch, EncodingError> {
654 encode_batch_with_metadata(data)
655}
656
657pub fn bars_to_arrow_record_batch_bytes(data: &[Bar]) -> Result<RecordBatch, EncodingError> {
666 encode_batch_with_metadata(data)
667}
668
669pub fn mark_prices_to_arrow_record_batch_bytes(
678 data: &[MarkPriceUpdate],
679) -> Result<RecordBatch, EncodingError> {
680 encode_batch_with_metadata(data)
681}
682
683pub fn index_prices_to_arrow_record_batch_bytes(
692 data: &[IndexPriceUpdate],
693) -> Result<RecordBatch, EncodingError> {
694 encode_batch_with_metadata(data)
695}
696
697#[expect(clippy::missing_panics_doc)] pub fn instrument_status_to_arrow_record_batch_bytes(
706 data: &[InstrumentStatus],
707) -> Result<RecordBatch, EncodingError> {
708 if data.is_empty() {
709 return Err(EncodingError::EmptyData);
710 }
711
712 let first = data.first().unwrap();
713 let metadata = first.metadata();
714 InstrumentStatus::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
715}
716
717#[expect(clippy::missing_panics_doc)] pub fn option_greeks_to_arrow_record_batch_bytes(
726 data: &[OptionGreeks],
727) -> Result<RecordBatch, EncodingError> {
728 if data.is_empty() {
729 return Err(EncodingError::EmptyData);
730 }
731
732 let first = data.first().unwrap();
733 let metadata = first.metadata();
734 OptionGreeks::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
735}
736
737pub fn instrument_closes_to_arrow_record_batch_bytes(
746 data: &[InstrumentClose],
747) -> Result<RecordBatch, EncodingError> {
748 encode_batch_with_metadata(data)
749}
750
751fn encode_batch_with_metadata<T>(data: &[T]) -> Result<RecordBatch, EncodingError>
752where
753 T: EncodeToRecordBatch,
754{
755 if data.is_empty() {
756 return Err(EncodingError::EmptyData);
757 }
758
759 let metadata = T::chunk_metadata(data);
760 if let Some(index) = data.iter().position(|value| value.metadata() != metadata) {
761 return Err(EncodingError::MixedMetadata { index });
762 }
763
764 T::encode_batch(&metadata, data).map_err(EncodingError::ArrowError)
765}
766
767#[cfg(test)]
768mod tests {
769 use nautilus_model::{
770 data::{
771 Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDepth10, QuoteTick,
772 depth::DEPTH10_LEN,
773 },
774 enums::{AggregationSource, BarAggregation, BookAction, OrderSide, PriceType},
775 identifiers::InstrumentId,
776 types::{PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity},
777 };
778 use rstest::rstest;
779
780 use super::*;
781
782 #[rstest]
783 fn test_quotes_to_arrow_record_batch_rejects_mixed_instruments() {
784 let first = QuoteTick::new(
785 InstrumentId::from("AAPL.XNAS"),
786 Price::from("100.01"),
787 Price::from("100.02"),
788 Quantity::from("10"),
789 Quantity::from("11"),
790 1.into(),
791 1.into(),
792 );
793 let second = QuoteTick::new(
794 InstrumentId::from("MSFT.XNAS"),
795 Price::from("200.01"),
796 Price::from("200.02"),
797 Quantity::from("20"),
798 Quantity::from("21"),
799 2.into(),
800 2.into(),
801 );
802
803 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
804
805 assert!(matches!(
806 result,
807 Err(EncodingError::MixedMetadata { index: 1 })
808 ));
809 }
810
811 #[rstest]
812 fn test_quotes_to_arrow_record_batch_rejects_mixed_precision() {
813 let instrument_id = InstrumentId::from("AAPL.XNAS");
814 let first = QuoteTick::new(
815 instrument_id,
816 Price::from("100.01"),
817 Price::from("100.02"),
818 Quantity::from("10.00"),
819 Quantity::from("11.00"),
820 1.into(),
821 1.into(),
822 );
823 let second = QuoteTick::new(
824 instrument_id,
825 Price::from("100.010"),
826 Price::from("100.020"),
827 Quantity::from("10.000"),
828 Quantity::from("11.000"),
829 2.into(),
830 2.into(),
831 );
832
833 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
834
835 assert!(matches!(
836 result,
837 Err(EncodingError::MixedMetadata { index: 1 })
838 ));
839 }
840
841 #[rstest]
842 fn test_bars_to_arrow_record_batch_rejects_mixed_bar_types() {
843 let instrument_id = InstrumentId::from("AAPL.XNAS");
844 let first_type = BarType::new(
845 instrument_id,
846 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
847 AggregationSource::Internal,
848 );
849 let second_type = BarType::new(
850 instrument_id,
851 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
852 AggregationSource::Internal,
853 );
854 let first = Bar::new(
855 first_type,
856 Price::from("100.01"),
857 Price::from("100.02"),
858 Price::from("100.00"),
859 Price::from("100.01"),
860 Quantity::from("10"),
861 1.into(),
862 1.into(),
863 );
864 let second = Bar::new(
865 second_type,
866 Price::from("100.01"),
867 Price::from("100.02"),
868 Price::from("100.00"),
869 Price::from("100.01"),
870 Quantity::from("11"),
871 2.into(),
872 2.into(),
873 );
874
875 let result = bars_to_arrow_record_batch_bytes(&[first, second]);
876
877 assert!(matches!(
878 result,
879 Err(EncodingError::MixedMetadata { index: 1 })
880 ));
881 }
882
883 #[rstest]
884 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_price_precision() {
885 let instrument_id = InstrumentId::from("AUD/USD.SIM");
886 let bid = BookOrder::new(
887 OrderSide::Buy,
888 Price::from("1.23"),
889 Quantity::from("100.00"),
890 1,
891 );
892 let ask = BookOrder::new(
893 OrderSide::Sell,
894 Price::from("1.24"),
895 Quantity::from("100.00"),
896 2,
897 );
898 let mut asks = [ask; DEPTH10_LEN];
899 asks[1].price = Price::from("1.241");
900 let depth = OrderBookDepth10::new(
901 instrument_id,
902 [bid; DEPTH10_LEN],
903 asks,
904 [1; DEPTH10_LEN],
905 [1; DEPTH10_LEN],
906 0,
907 1,
908 1.into(),
909 1.into(),
910 );
911
912 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
913
914 assert!(matches!(
915 result,
916 Err(EncodingError::MixedMetadata { index: 0 })
917 ));
918 }
919
920 #[rstest]
921 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_size_precision() {
922 let instrument_id = InstrumentId::from("AUD/USD.SIM");
923 let bid = BookOrder::new(
924 OrderSide::Buy,
925 Price::from("1.23"),
926 Quantity::from("100.00"),
927 1,
928 );
929 let ask = BookOrder::new(
930 OrderSide::Sell,
931 Price::from("1.24"),
932 Quantity::from("100.00"),
933 2,
934 );
935 let mut bids = [bid; DEPTH10_LEN];
936 bids[1].size = Quantity::from("100.000");
937 let depth = OrderBookDepth10::new(
938 instrument_id,
939 bids,
940 [ask; DEPTH10_LEN],
941 [1; DEPTH10_LEN],
942 [1; DEPTH10_LEN],
943 0,
944 1,
945 1.into(),
946 1.into(),
947 );
948
949 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
950
951 assert!(matches!(
952 result,
953 Err(EncodingError::MixedMetadata { index: 0 })
954 ));
955 }
956
957 #[rstest]
958 fn test_depth10_to_arrow_record_batch_uses_first_defined_level_precision() {
959 let instrument_id = InstrumentId::from("AUD/USD.SIM");
960 let bid = BookOrder::new(
961 OrderSide::Buy,
962 Price::from("1.23"),
963 Quantity::from("100.00"),
964 1,
965 );
966 let ask = BookOrder::new(
967 OrderSide::Sell,
968 Price::from("1.24"),
969 Quantity::from("100.00"),
970 2,
971 );
972 let mut bids = [bid; DEPTH10_LEN];
973 bids[0].price = Price::from_raw(PRICE_UNDEF, 0);
974 bids[0].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
975 let depth = OrderBookDepth10::new(
976 instrument_id,
977 bids,
978 [ask; DEPTH10_LEN],
979 [0; DEPTH10_LEN],
980 [1; DEPTH10_LEN],
981 0,
982 1,
983 1.into(),
984 1.into(),
985 );
986
987 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]).unwrap();
988
989 assert_eq!(
990 result.schema().metadata().get(KEY_PRICE_PRECISION).unwrap(),
991 "2"
992 );
993 assert_eq!(
994 result.schema().metadata().get(KEY_SIZE_PRECISION).unwrap(),
995 "2"
996 );
997 }
998
999 #[rstest]
1000 #[case::price(true)]
1001 #[case::size(false)]
1002 fn test_depth10_to_arrow_record_batch_rejects_partial_undefined_level(
1003 #[case] price_undefined: bool,
1004 ) {
1005 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1006 let bid = BookOrder::new(
1007 OrderSide::Buy,
1008 Price::from("1.23"),
1009 Quantity::from("100.00"),
1010 1,
1011 );
1012 let ask = BookOrder::new(
1013 OrderSide::Sell,
1014 Price::from("1.24"),
1015 Quantity::from("100.00"),
1016 2,
1017 );
1018 let mut asks = [ask; DEPTH10_LEN];
1019 if price_undefined {
1020 asks[1].price = Price::from_raw(PRICE_UNDEF, 0);
1021 } else {
1022 asks[1].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1023 }
1024 let depth = OrderBookDepth10::new(
1025 instrument_id,
1026 [bid; DEPTH10_LEN],
1027 asks,
1028 [1; DEPTH10_LEN],
1029 [1; DEPTH10_LEN],
1030 0,
1031 1,
1032 1.into(),
1033 1.into(),
1034 );
1035
1036 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1037
1038 assert!(matches!(
1039 result,
1040 Err(EncodingError::MixedMetadata { index: 0 })
1041 ));
1042 }
1043
1044 #[rstest]
1045 fn test_deltas_to_arrow_record_batch_skips_leading_clears_for_precision() {
1046 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1047 let first = OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into());
1048 let second = OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into());
1049 let third = OrderBookDelta::new(
1050 instrument_id,
1051 BookAction::Add,
1052 BookOrder::new(
1053 OrderSide::Buy,
1054 Price::from("1.23"),
1055 Quantity::from("100.000000"),
1056 1,
1057 ),
1058 0,
1059 2,
1060 3.into(),
1061 3.into(),
1062 );
1063 let expected = vec![first, second, third];
1064
1065 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1066 let metadata = batch.schema().metadata().clone();
1067 assert_eq!(
1068 metadata.get(KEY_PRICE_PRECISION).map(String::as_str),
1069 Some("2")
1070 );
1071 assert_eq!(
1072 metadata.get(KEY_SIZE_PRECISION).map(String::as_str),
1073 Some("6")
1074 );
1075
1076 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1077
1078 assert_eq!(decoded, expected);
1079 assert_eq!(decoded[2].order.price.precision, 2);
1080 assert_eq!(decoded[2].order.size.precision, 6);
1081 }
1082
1083 #[rstest]
1084 fn test_deltas_to_arrow_record_batch_all_clear_roundtrip() {
1085 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1086 let expected = vec![
1087 OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into()),
1088 OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into()),
1089 ];
1090
1091 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1092 let metadata = batch.schema().metadata().clone();
1093 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1094
1095 assert_eq!(decoded, expected);
1096 }
1097
1098 #[rstest]
1099 fn test_deltas_to_arrow_record_batch_rejects_mixed_precision() {
1100 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1101 let first = OrderBookDelta::new(
1102 instrument_id,
1103 BookAction::Add,
1104 BookOrder::new(
1105 OrderSide::Buy,
1106 Price::from("1.23"),
1107 Quantity::from("100.00"),
1108 1,
1109 ),
1110 0,
1111 1,
1112 1.into(),
1113 1.into(),
1114 );
1115 let second = OrderBookDelta::new(
1116 instrument_id,
1117 BookAction::Update,
1118 BookOrder::new(
1119 OrderSide::Buy,
1120 Price::from("1.234"),
1121 Quantity::from("100.000"),
1122 1,
1123 ),
1124 0,
1125 2,
1126 2.into(),
1127 2.into(),
1128 );
1129
1130 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1131
1132 assert!(matches!(
1133 result,
1134 Err(EncodingError::MixedMetadata { index: 1 })
1135 ));
1136 }
1137
1138 #[rstest]
1139 fn test_deltas_to_arrow_record_batch_rejects_mixed_instruments() {
1140 let first = OrderBookDelta::clear(InstrumentId::from("AUD/USD.SIM"), 0, 1.into(), 1.into());
1141 let second = OrderBookDelta::new(
1142 InstrumentId::from("EUR/USD.SIM"),
1143 BookAction::Add,
1144 BookOrder::new(
1145 OrderSide::Buy,
1146 Price::from("1.23"),
1147 Quantity::from("100.00"),
1148 1,
1149 ),
1150 0,
1151 1,
1152 2.into(),
1153 2.into(),
1154 );
1155
1156 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1157
1158 assert!(matches!(
1160 result,
1161 Err(EncodingError::MixedMetadata { index: 0 })
1162 ));
1163 }
1164}