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)]
822mod tests {
823 use nautilus_model::{
824 data::{
825 Bar, BarSpecification, BarType, BookOrder, OrderBookDelta, OrderBookDepth10, QuoteTick,
826 depth::DEPTH10_LEN,
827 },
828 enums::{AggregationSource, BarAggregation, BookAction, OrderSide, PriceType},
829 identifiers::InstrumentId,
830 types::{PRICE_UNDEF, Price, QUANTITY_UNDEF, Quantity},
831 };
832 use rstest::rstest;
833
834 use super::*;
835
836 #[rstest]
837 fn test_quotes_to_arrow_record_batch_rejects_mixed_instruments() {
838 let first = QuoteTick::new(
839 InstrumentId::from("AAPL.XNAS"),
840 Price::from("100.01"),
841 Price::from("100.02"),
842 Quantity::from("10"),
843 Quantity::from("11"),
844 1.into(),
845 1.into(),
846 );
847 let second = QuoteTick::new(
848 InstrumentId::from("MSFT.XNAS"),
849 Price::from("200.01"),
850 Price::from("200.02"),
851 Quantity::from("20"),
852 Quantity::from("21"),
853 2.into(),
854 2.into(),
855 );
856
857 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
858
859 assert!(matches!(
860 result,
861 Err(EncodingError::MixedMetadata { index: 1 })
862 ));
863 }
864
865 #[rstest]
866 fn test_quotes_to_arrow_record_batch_rejects_mixed_precision() {
867 let instrument_id = InstrumentId::from("AAPL.XNAS");
868 let first = QuoteTick::new(
869 instrument_id,
870 Price::from("100.01"),
871 Price::from("100.02"),
872 Quantity::from("10.00"),
873 Quantity::from("11.00"),
874 1.into(),
875 1.into(),
876 );
877 let second = QuoteTick::new(
878 instrument_id,
879 Price::from("100.010"),
880 Price::from("100.020"),
881 Quantity::from("10.000"),
882 Quantity::from("11.000"),
883 2.into(),
884 2.into(),
885 );
886
887 let result = quotes_to_arrow_record_batch_bytes(&[first, second]);
888
889 assert!(matches!(
890 result,
891 Err(EncodingError::MixedMetadata { index: 1 })
892 ));
893 }
894
895 #[rstest]
896 fn test_bars_to_arrow_record_batch_rejects_mixed_bar_types() {
897 let instrument_id = InstrumentId::from("AAPL.XNAS");
898 let first_type = BarType::new(
899 instrument_id,
900 BarSpecification::new(1, BarAggregation::Minute, PriceType::Last),
901 AggregationSource::Internal,
902 );
903 let second_type = BarType::new(
904 instrument_id,
905 BarSpecification::new(5, BarAggregation::Minute, PriceType::Last),
906 AggregationSource::Internal,
907 );
908 let first = Bar::new(
909 first_type,
910 Price::from("100.01"),
911 Price::from("100.02"),
912 Price::from("100.00"),
913 Price::from("100.01"),
914 Quantity::from("10"),
915 1.into(),
916 1.into(),
917 );
918 let second = Bar::new(
919 second_type,
920 Price::from("100.01"),
921 Price::from("100.02"),
922 Price::from("100.00"),
923 Price::from("100.01"),
924 Quantity::from("11"),
925 2.into(),
926 2.into(),
927 );
928
929 let result = bars_to_arrow_record_batch_bytes(&[first, second]);
930
931 assert!(matches!(
932 result,
933 Err(EncodingError::MixedMetadata { index: 1 })
934 ));
935 }
936
937 #[rstest]
938 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_price_precision() {
939 let instrument_id = InstrumentId::from("AUD/USD.SIM");
940 let bid = BookOrder::new(
941 OrderSide::Buy,
942 Price::from("1.23"),
943 Quantity::from("100.00"),
944 1,
945 );
946 let ask = BookOrder::new(
947 OrderSide::Sell,
948 Price::from("1.24"),
949 Quantity::from("100.00"),
950 2,
951 );
952 let mut asks = [ask; DEPTH10_LEN];
953 asks[1].price = Price::from("1.241");
954 let depth = OrderBookDepth10::new(
955 instrument_id,
956 [bid; DEPTH10_LEN],
957 asks,
958 [1; DEPTH10_LEN],
959 [1; DEPTH10_LEN],
960 0,
961 1,
962 1.into(),
963 1.into(),
964 );
965
966 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
967
968 assert!(matches!(
969 result,
970 Err(EncodingError::MixedMetadata { index: 0 })
971 ));
972 }
973
974 #[rstest]
975 fn test_depth10_to_arrow_record_batch_rejects_mixed_level_size_precision() {
976 let instrument_id = InstrumentId::from("AUD/USD.SIM");
977 let bid = BookOrder::new(
978 OrderSide::Buy,
979 Price::from("1.23"),
980 Quantity::from("100.00"),
981 1,
982 );
983 let ask = BookOrder::new(
984 OrderSide::Sell,
985 Price::from("1.24"),
986 Quantity::from("100.00"),
987 2,
988 );
989 let mut bids = [bid; DEPTH10_LEN];
990 bids[1].size = Quantity::from("100.000");
991 let depth = OrderBookDepth10::new(
992 instrument_id,
993 bids,
994 [ask; DEPTH10_LEN],
995 [1; DEPTH10_LEN],
996 [1; DEPTH10_LEN],
997 0,
998 1,
999 1.into(),
1000 1.into(),
1001 );
1002
1003 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1004
1005 assert!(matches!(
1006 result,
1007 Err(EncodingError::MixedMetadata { index: 0 })
1008 ));
1009 }
1010
1011 #[rstest]
1012 fn test_depth10_to_arrow_record_batch_uses_first_defined_level_precision() {
1013 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1014 let bid = BookOrder::new(
1015 OrderSide::Buy,
1016 Price::from("1.23"),
1017 Quantity::from("100.00"),
1018 1,
1019 );
1020 let ask = BookOrder::new(
1021 OrderSide::Sell,
1022 Price::from("1.24"),
1023 Quantity::from("100.00"),
1024 2,
1025 );
1026 let mut bids = [bid; DEPTH10_LEN];
1027 bids[0].price = Price::from_raw(PRICE_UNDEF, 0);
1028 bids[0].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1029 let depth = OrderBookDepth10::new(
1030 instrument_id,
1031 bids,
1032 [ask; DEPTH10_LEN],
1033 [0; DEPTH10_LEN],
1034 [1; DEPTH10_LEN],
1035 0,
1036 1,
1037 1.into(),
1038 1.into(),
1039 );
1040
1041 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]).unwrap();
1042
1043 assert_eq!(
1044 result.schema().metadata().get(KEY_PRICE_PRECISION).unwrap(),
1045 "2"
1046 );
1047 assert_eq!(
1048 result.schema().metadata().get(KEY_SIZE_PRECISION).unwrap(),
1049 "2"
1050 );
1051 }
1052
1053 #[rstest]
1054 #[case::price(true)]
1055 #[case::size(false)]
1056 fn test_depth10_to_arrow_record_batch_rejects_partial_undefined_level(
1057 #[case] price_undefined: bool,
1058 ) {
1059 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1060 let bid = BookOrder::new(
1061 OrderSide::Buy,
1062 Price::from("1.23"),
1063 Quantity::from("100.00"),
1064 1,
1065 );
1066 let ask = BookOrder::new(
1067 OrderSide::Sell,
1068 Price::from("1.24"),
1069 Quantity::from("100.00"),
1070 2,
1071 );
1072 let mut asks = [ask; DEPTH10_LEN];
1073 if price_undefined {
1074 asks[1].price = Price::from_raw(PRICE_UNDEF, 0);
1075 } else {
1076 asks[1].size = Quantity::from_raw(QUANTITY_UNDEF, 0);
1077 }
1078 let depth = OrderBookDepth10::new(
1079 instrument_id,
1080 [bid; DEPTH10_LEN],
1081 asks,
1082 [1; DEPTH10_LEN],
1083 [1; DEPTH10_LEN],
1084 0,
1085 1,
1086 1.into(),
1087 1.into(),
1088 );
1089
1090 let result = book_depth10_to_arrow_record_batch_bytes(&[depth]);
1091
1092 assert!(matches!(
1093 result,
1094 Err(EncodingError::MixedMetadata { index: 0 })
1095 ));
1096 }
1097
1098 #[rstest]
1099 fn test_deltas_to_arrow_record_batch_skips_leading_clears_for_precision() {
1100 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1101 let first = OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into());
1102 let second = OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into());
1103 let third = OrderBookDelta::new(
1104 instrument_id,
1105 BookAction::Add,
1106 BookOrder::new(
1107 OrderSide::Buy,
1108 Price::from("1.23"),
1109 Quantity::from("100.000000"),
1110 1,
1111 ),
1112 0,
1113 2,
1114 3.into(),
1115 3.into(),
1116 );
1117 let expected = vec![first, second, third];
1118
1119 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1120 let metadata = batch.schema().metadata().clone();
1121 assert_eq!(
1122 metadata.get(KEY_PRICE_PRECISION).map(String::as_str),
1123 Some("2")
1124 );
1125 assert_eq!(
1126 metadata.get(KEY_SIZE_PRECISION).map(String::as_str),
1127 Some("6")
1128 );
1129
1130 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1131
1132 assert_eq!(decoded, expected);
1133 assert_eq!(decoded[2].order.price.precision, 2);
1134 assert_eq!(decoded[2].order.size.precision, 6);
1135 }
1136
1137 #[rstest]
1138 fn test_deltas_to_arrow_record_batch_all_clear_roundtrip() {
1139 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1140 let expected = vec![
1141 OrderBookDelta::clear(instrument_id, 0, 1.into(), 1.into()),
1142 OrderBookDelta::clear(instrument_id, 1, 2.into(), 2.into()),
1143 ];
1144
1145 let batch = book_deltas_to_arrow_record_batch_bytes(&expected).unwrap();
1146 let metadata = batch.schema().metadata().clone();
1147 let decoded = OrderBookDelta::decode_batch(&metadata, batch).unwrap();
1148
1149 assert_eq!(decoded, expected);
1150 }
1151
1152 #[rstest]
1153 fn test_deltas_to_arrow_record_batch_rejects_mixed_precision() {
1154 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1155 let first = OrderBookDelta::new(
1156 instrument_id,
1157 BookAction::Add,
1158 BookOrder::new(
1159 OrderSide::Buy,
1160 Price::from("1.23"),
1161 Quantity::from("100.00"),
1162 1,
1163 ),
1164 0,
1165 1,
1166 1.into(),
1167 1.into(),
1168 );
1169 let second = OrderBookDelta::new(
1170 instrument_id,
1171 BookAction::Update,
1172 BookOrder::new(
1173 OrderSide::Buy,
1174 Price::from("1.234"),
1175 Quantity::from("100.000"),
1176 1,
1177 ),
1178 0,
1179 2,
1180 2.into(),
1181 2.into(),
1182 );
1183
1184 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1185
1186 assert!(matches!(
1187 result,
1188 Err(EncodingError::MixedMetadata { index: 1 })
1189 ));
1190 }
1191
1192 #[rstest]
1193 fn test_deltas_to_arrow_record_batch_rejects_mixed_instruments() {
1194 let first = OrderBookDelta::clear(InstrumentId::from("AUD/USD.SIM"), 0, 1.into(), 1.into());
1195 let second = OrderBookDelta::new(
1196 InstrumentId::from("EUR/USD.SIM"),
1197 BookAction::Add,
1198 BookOrder::new(
1199 OrderSide::Buy,
1200 Price::from("1.23"),
1201 Quantity::from("100.00"),
1202 1,
1203 ),
1204 0,
1205 1,
1206 2.into(),
1207 2.into(),
1208 );
1209
1210 let result = book_deltas_to_arrow_record_batch_bytes(&[first, second]);
1211
1212 assert!(matches!(
1214 result,
1215 Err(EncodingError::MixedMetadata { index: 0 })
1216 ));
1217 }
1218}