1use std::{collections::HashMap, str::FromStr, sync::Arc};
23
24use arrow::{
25 array::{
26 Array, ArrayRef, BooleanArray, Decimal128Array, Float64Array, Float64Builder, Int8Array,
27 Int16Array, ListArray, StringArray, StringBuilder, StructArray, TimestampNanosecondArray,
28 TimestampNanosecondBuilder, UInt8Array, UInt8Builder, UInt32Array, UInt64Array,
29 UInt64Builder,
30 },
31 compute::cast,
32 datatypes::{DataType, Field, Schema},
33 record_batch::RecordBatch,
34};
35use nautilus_model::{
36 data::{
37 Bar, BarType, FundingRateUpdate, IndexPriceUpdate, InstrumentClose, InstrumentStatus,
38 MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
39 TradeTick, get_arrow_schema,
40 },
41 enums::{AggressorSide, BookAction, InstrumentCloseType, OrderSide},
42 instruments::InstrumentAny,
43 types::{Price, Quantity},
44};
45use rust_decimal::{Decimal, prelude::ToPrimitive};
46
47use super::{
48 ArrowSchemaProvider, EncodingError, KEY_BAR_TYPE, KEY_IDENTIFIER, KEY_INSTRUMENT_ID,
49 KEY_PRICE_PRECISION, KEY_SIZE_PRECISION, U64ColumnRef,
50 custom::CustomDataDecoder,
51 decode_decimal_price, decode_decimal_quantity,
52 depth_display::{DepthSideBuilder, schema as depth_schema},
53 display_conversion::{
54 float64_field, price_to_f64, quantity_to_f64, timestamp_field, utf8_field,
55 },
56 extract_column, extract_column_string, fixed_decimal_data_type,
57};
58use crate::arrow::timestamp_data_type;
59
60struct CatalogDisplayFns {
61 schema: fn() -> Schema,
62 convert: fn(&HashMap<String, String>, &RecordBatch) -> Result<RecordBatch, EncodingError>,
63}
64
65trait CatalogDisplay {
66 const FUNCTIONS: Option<CatalogDisplayFns>;
67}
68
69impl CatalogDisplay for InstrumentAny {
70 const FUNCTIONS: Option<CatalogDisplayFns> = None;
71}
72
73impl CatalogDisplay for QuoteTick {
74 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
75 schema: quotes_schema,
76 convert: convert_quotes,
77 });
78}
79
80impl CatalogDisplay for TradeTick {
81 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
82 schema: trades_schema,
83 convert: convert_trades,
84 });
85}
86
87impl CatalogDisplay for Bar {
88 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
89 schema: bars_schema,
90 convert: convert_bars,
91 });
92}
93
94impl CatalogDisplay for OrderBookDelta {
95 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
96 schema: deltas_schema,
97 convert: convert_deltas,
98 });
99}
100
101impl CatalogDisplay for OrderBookDepth {
102 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
103 schema: depth_schema,
104 convert: convert_depths,
105 });
106}
107
108impl CatalogDisplay for MarkPriceUpdate {
109 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
110 schema: price_update_schema,
111 convert: convert_price_updates,
112 });
113}
114
115impl CatalogDisplay for IndexPriceUpdate {
116 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
117 schema: price_update_schema,
118 convert: convert_price_updates,
119 });
120}
121
122impl CatalogDisplay for FundingRateUpdate {
123 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
124 schema: funding_rate_schema,
125 convert: convert_funding_rates_with_metadata,
126 });
127}
128
129impl CatalogDisplay for InstrumentStatus {
130 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
131 schema: instrument_status_schema,
132 convert: convert_instrument_status_with_metadata,
133 });
134}
135
136impl CatalogDisplay for OptionGreeks {
137 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
138 schema: option_greeks_schema,
139 convert: convert_option_greeks_with_metadata,
140 });
141}
142
143impl CatalogDisplay for InstrumentClose {
144 const FUNCTIONS: Option<CatalogDisplayFns> = Some(CatalogDisplayFns {
145 schema: instrument_closes_schema,
146 convert: convert_instrument_closes,
147 });
148}
149
150macro_rules! define_catalog_display_lookup {
151 ($(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?) => {
152 fn builtin_catalog_display(data_type: &NautilusDataType) -> Option<CatalogDisplayFns> {
153 match data_type {
154 $(
155 NautilusDataType::$variant => <$type as CatalogDisplay>::FUNCTIONS,
156 )+
157 _ => None,
158 }
159 }
160 };
161}
162
163nautilus_model::for_each_data_type!(define_catalog_display_lookup);
164
165macro_rules! define_catalog_raw_schema_lookup {
166 (
167 ($_instrument_variant:ident, $_instrument_type:ident, $_instrument_data:ident, $_instrument_batch:ident, $_instrument_prefix:literal),
168 $(($variant:ident, $type:ident, $data:ident, $batch:ident, $prefix:literal)),+ $(,)?
169 ) => {
170 fn builtin_catalog_raw_schema(data_type: &NautilusDataType) -> Option<Schema> {
171 match data_type {
172 $(
173 NautilusDataType::$variant => {
174 Some(<$type as ArrowSchemaProvider>::get_schema(None))
175 }
176 )+
177 _ => None,
178 }
179 }
180 };
181}
182
183nautilus_model::for_each_data_type!(define_catalog_raw_schema_lookup);
184
185pub fn catalog_raw_schema(data_type: &NautilusDataType) -> Result<Schema, EncodingError> {
191 if let NautilusDataType::Custom { type_name } = data_type {
192 if get_arrow_schema(type_name).is_none() {
193 return Err(unsupported_display_type(data_type));
194 }
195 let metadata = HashMap::from([("type_name".to_string(), type_name.clone())]);
196 return Ok(super::schema_with_identifier_column(
197 &CustomDataDecoder::get_schema(Some(metadata)),
198 ));
199 }
200
201 builtin_catalog_raw_schema(data_type).ok_or_else(|| unsupported_display_type(data_type))
202}
203
204#[allow(
210 clippy::match_wildcard_for_single_variants,
211 unreachable_patterns,
212 reason = "NautilusDataType::Defi is controlled by nautilus-model features, not this crate"
213)]
214pub fn catalog_display_schema(data_type: &NautilusDataType) -> Result<Schema, EncodingError> {
215 if matches!(data_type, NautilusDataType::Custom { .. }) {
216 let schema = catalog_raw_schema(data_type)?;
217 let batch = RecordBatch::new_empty(Arc::new(schema));
218 return catalog_record_batch_to_display(data_type, batch.schema().metadata(), &batch)
219 .map(|batch| batch.schema().as_ref().clone());
220 }
221
222 builtin_catalog_display(data_type)
223 .map(|functions| (functions.schema)())
224 .ok_or_else(|| unsupported_display_type(data_type))
225}
226
227#[allow(
234 clippy::match_wildcard_for_single_variants,
235 unreachable_patterns,
236 reason = "NautilusDataType::Defi is controlled by nautilus-model features, not this crate"
237)]
238pub fn catalog_record_batch_to_display(
239 data_type: &NautilusDataType,
240 metadata: &HashMap<String, String>,
241 record_batch: &RecordBatch,
242) -> Result<RecordBatch, EncodingError> {
243 let display_batch = if matches!(data_type, NautilusDataType::Custom { .. }) {
244 convert_custom(record_batch)
245 } else if let Some(functions) = builtin_catalog_display(data_type) {
246 (functions.convert)(metadata, record_batch)
247 } else {
248 Err(unsupported_display_type(data_type))
249 }?;
250
251 append_identifier_column_if_present(display_batch, record_batch)
252}
253
254fn unsupported_display_type(data_type: &NautilusDataType) -> EncodingError {
255 EncodingError::ParseError(
256 "data_type",
257 format!("unsupported display catalog data type `{data_type}`"),
258 )
259}
260
261fn convert_funding_rates_with_metadata(
262 _: &HashMap<String, String>,
263 batch: &RecordBatch,
264) -> Result<RecordBatch, EncodingError> {
265 convert_funding_rates(batch)
266}
267
268fn convert_option_greeks_with_metadata(
269 _: &HashMap<String, String>,
270 batch: &RecordBatch,
271) -> Result<RecordBatch, EncodingError> {
272 convert_option_greeks(batch)
273}
274
275fn convert_instrument_status_with_metadata(
276 _: &HashMap<String, String>,
277 batch: &RecordBatch,
278) -> Result<RecordBatch, EncodingError> {
279 convert_instrument_status(batch)
280}
281
282fn append_identifier_column_if_present(
283 display_batch: RecordBatch,
284 catalog_batch: &RecordBatch,
285) -> Result<RecordBatch, EncodingError> {
286 if display_batch.schema().index_of(KEY_IDENTIFIER).is_ok() {
287 return Ok(display_batch);
288 }
289
290 let Ok(identifier_index) = catalog_batch.schema().index_of(KEY_IDENTIFIER) else {
291 return Ok(display_batch);
292 };
293
294 let catalog_schema = catalog_batch.schema();
295 let identifier_field = catalog_schema.field(identifier_index);
296 let (identifier_field, identifier_column) =
297 if identifier_field.data_type() == &DataType::Utf8View {
298 (
299 Field::new(
300 identifier_field.name(),
301 DataType::Utf8,
302 identifier_field.is_nullable(),
303 ),
304 cast(
305 catalog_batch.column(identifier_index).as_ref(),
306 &DataType::Utf8,
307 )?,
308 )
309 } else {
310 (
311 identifier_field.clone(),
312 catalog_batch.column(identifier_index).clone(),
313 )
314 };
315
316 let mut fields = display_batch.schema().fields().to_vec();
317 fields.push(Arc::new(identifier_field));
318 let mut columns = display_batch.columns().to_vec();
319 columns.push(identifier_column);
320
321 RecordBatch::try_new(Arc::new(Schema::new(fields)), columns).map_err(EncodingError::from)
322}
323
324fn parse_price_precision(metadata: &HashMap<String, String>) -> Result<u8, EncodingError> {
325 parse_precision(metadata, KEY_PRICE_PRECISION)
326}
327
328fn parse_size_precision(metadata: &HashMap<String, String>) -> Result<u8, EncodingError> {
329 parse_precision(metadata, KEY_SIZE_PRECISION)
330}
331
332fn parse_precision(
333 metadata: &HashMap<String, String>,
334 key: &'static str,
335) -> Result<u8, EncodingError> {
336 metadata
337 .get(key)
338 .ok_or(EncodingError::MissingMetadata(key))?
339 .parse::<u8>()
340 .map_err(|e| EncodingError::ParseError(key, e.to_string()))
341}
342
343fn instrument_id(metadata: &HashMap<String, String>) -> Result<&str, EncodingError> {
344 metadata
345 .get(KEY_INSTRUMENT_ID)
346 .map(String::as_str)
347 .ok_or_else(|| EncodingError::MissingMetadata(KEY_INSTRUMENT_ID))
348}
349
350fn bar_type(metadata: &HashMap<String, String>) -> Result<BarType, EncodingError> {
351 let value = metadata
352 .get(KEY_BAR_TYPE)
353 .ok_or_else(|| EncodingError::MissingMetadata(KEY_BAR_TYPE))?;
354 BarType::from_str(value).map_err(|e| EncodingError::ParseError(KEY_BAR_TYPE, e.to_string()))
355}
356
357fn constant_string_column(value: &str, len: usize) -> ArrayRef {
358 Arc::new(StringArray::from_iter_values(std::iter::repeat_n(
359 value, len,
360 )))
361}
362
363struct FixedPrecisionColumn<'a>(&'a Decimal128Array);
364
365impl FixedPrecisionColumn<'_> {
366 fn len(&self) -> usize {
367 self.0.len()
368 }
369 fn is_null(&self, row: usize) -> bool {
370 self.0.is_null(row)
371 }
372 fn price(
373 &self,
374 precision: u8,
375 field: &'static str,
376 row: usize,
377 ) -> Result<Price, EncodingError> {
378 decode_decimal_price(self.0, precision, field, row)
379 }
380 fn quantity(
381 &self,
382 precision: u8,
383 field: &'static str,
384 row: usize,
385 ) -> Result<Quantity, EncodingError> {
386 decode_decimal_quantity(self.0, precision, field, row)
387 }
388}
389
390struct NanosColumn<'a>(&'a TimestampNanosecondArray);
391
392impl NanosColumn<'_> {
393 fn len(&self) -> usize {
394 self.0.len()
395 }
396 fn is_null(&self, row: usize) -> bool {
397 self.0.is_null(row)
398 }
399 fn value(&self, row: usize) -> i64 {
400 self.0.value(row)
401 }
402}
403
404enum U8Column<'a> {
405 UInt8(&'a UInt8Array),
406 Int8(&'a Int8Array),
407 Int16(&'a Int16Array),
408}
409
410impl U8Column<'_> {
411 fn value(&self, row: usize, field: &'static str) -> Result<u8, EncodingError> {
412 match self {
413 Self::UInt8(values) => Ok(values.value(row)),
414 Self::Int8(values) => u8::try_from(values.value(row)).map_err(|_| {
415 EncodingError::ParseError(field, format!("Invalid negative value at row {row}"))
416 }),
417 Self::Int16(values) => u8::try_from(values.value(row)).map_err(|_| {
418 EncodingError::ParseError(field, format!("Value out of u8 range at row {row}"))
419 }),
420 }
421 }
422}
423
424fn fixed_col<'a>(
425 batch: &'a RecordBatch,
426 name: &'static str,
427) -> Result<FixedPrecisionColumn<'a>, EncodingError> {
428 Ok(FixedPrecisionColumn(super::extract_decimal_column(
429 batch, name,
430 )?))
431}
432
433fn u8_col<'a>(batch: &'a RecordBatch, name: &'static str) -> Result<U8Column<'a>, EncodingError> {
434 let index = batch.schema().index_of(name)?;
435 let column = batch.column(index);
436 match column.data_type() {
437 DataType::UInt8 => Ok(U8Column::UInt8(extract_column::<UInt8Array>(
438 batch.columns(),
439 name,
440 index,
441 DataType::UInt8,
442 )?)),
443 DataType::Int8 => Ok(U8Column::Int8(extract_column::<Int8Array>(
444 batch.columns(),
445 name,
446 index,
447 DataType::Int8,
448 )?)),
449 DataType::Int16 => Ok(U8Column::Int16(extract_column::<Int16Array>(
450 batch.columns(),
451 name,
452 index,
453 DataType::Int16,
454 )?)),
455 data_type => Err(EncodingError::InvalidColumnType(
456 name,
457 index,
458 DataType::UInt8,
459 data_type.clone(),
460 )),
461 }
462}
463
464fn u64_value(
465 values: &U64ColumnRef<'_>,
466 row: usize,
467 field: &'static str,
468) -> Result<u64, EncodingError> {
469 values.value(row).ok_or_else(|| {
470 EncodingError::ParseError(field, format!("Invalid negative value at row {row}"))
471 })
472}
473
474fn u64_col<'a>(
475 batch: &'a RecordBatch,
476 name: &'static str,
477) -> Result<U64ColumnRef<'a>, EncodingError> {
478 let index = batch.schema().index_of(name)?;
479 let column = batch.column(index);
480 U64ColumnRef::try_from_array(column.as_ref()).ok_or_else(|| {
481 EncodingError::InvalidColumnType(name, index, DataType::UInt64, column.data_type().clone())
482 })
483}
484
485fn nanos_col<'a>(
486 batch: &'a RecordBatch,
487 name: &'static str,
488) -> Result<NanosColumn<'a>, EncodingError> {
489 let index = batch.schema().index_of(name)?;
490 let column = batch.column(index);
491 let expected = timestamp_data_type();
492 if column.data_type() != &expected {
493 return Err(EncodingError::InvalidColumnType(
494 name,
495 index,
496 expected,
497 column.data_type().clone(),
498 ));
499 }
500 Ok(NanosColumn(extract_column::<TimestampNanosecondArray>(
501 batch.columns(),
502 name,
503 index,
504 expected,
505 )?))
506}
507
508fn f64_col<'a>(
509 batch: &'a RecordBatch,
510 name: &'static str,
511) -> Result<&'a Float64Array, EncodingError> {
512 let index = batch.schema().index_of(name)?;
513 extract_column::<Float64Array>(batch.columns(), name, index, DataType::Float64)
514}
515
516fn bool_col<'a>(
517 batch: &'a RecordBatch,
518 name: &'static str,
519) -> Result<&'a BooleanArray, EncodingError> {
520 let index = batch.schema().index_of(name)?;
521 extract_column::<BooleanArray>(batch.columns(), name, index, DataType::Boolean)
522}
523
524fn timestamp_array_from_nanos(values: &NanosColumn<'_>) -> ArrayRef {
525 let mut builder = TimestampNanosecondBuilder::with_capacity(values.len())
526 .with_data_type(timestamp_data_type());
527
528 for row in 0..values.len() {
529 append_timestamp(&mut builder, values, row);
530 }
531 Arc::new(builder.finish())
532}
533
534fn append_timestamp(
535 builder: &mut TimestampNanosecondBuilder,
536 values: &NanosColumn<'_>,
537 row: usize,
538) {
539 if values.is_null(row) {
540 builder.append_null();
541 } else {
542 builder.append_value(values.value(row));
543 }
544}
545
546fn timestamp_field_from(field: &Field) -> Field {
547 Field::new(field.name(), timestamp_data_type(), field.is_nullable())
548}
549
550fn fixed_precision_field_to_f64(
551 values: &FixedPrecisionColumn<'_>,
552 precision: u8,
553 is_price: bool,
554) -> Result<ArrayRef, EncodingError> {
555 let mut builder = Float64Builder::with_capacity(values.len());
556 for row in 0..values.len() {
557 if values.is_null(row) {
558 builder.append_null();
559 } else if is_price {
560 append_price(&mut builder, values, precision, "custom", row)?;
561 } else {
562 append_quantity(&mut builder, values, precision, "custom", row)?;
563 }
564 }
565
566 Ok(Arc::new(builder.finish()))
567}
568
569fn custom_fixed_precision(
570 field_name: &str,
571 metadata: &HashMap<String, String>,
572) -> Option<(u8, bool)> {
573 let normalized = field_name.to_ascii_lowercase();
574 let field_precision = metadata
575 .get(&format!("{field_name}_precision"))
576 .and_then(|value| value.parse::<u8>().ok());
577
578 match metadata
579 .get(&format!("{field_name}_kind"))
580 .map(String::as_str)
581 {
582 Some("price") => return field_precision.map(|precision| (precision, true)),
583 Some("quantity") => return field_precision.map(|precision| (precision, false)),
584 _ => {}
585 }
586 let is_quantity = matches!(
587 normalized.as_str(),
588 "size" | "quantity" | "qty" | "volume" | "bid_size" | "ask_size"
589 ) || normalized.ends_with("_size")
590 || normalized.ends_with("_quantity")
591 || normalized.ends_with("_qty")
592 || normalized.ends_with("_volume");
593
594 if is_quantity {
595 return field_precision
596 .or_else(|| {
597 metadata
598 .get(KEY_SIZE_PRECISION)
599 .and_then(|value| value.parse::<u8>().ok())
600 })
601 .map(|precision| (precision, false));
602 }
603
604 if let Some(precision) = field_precision {
605 return Some((precision, true));
606 }
607
608 let is_price = matches!(
609 normalized.as_str(),
610 "price"
611 | "value"
612 | "bid"
613 | "ask"
614 | "bid_price"
615 | "ask_price"
616 | "open"
617 | "high"
618 | "low"
619 | "close"
620 ) || normalized.ends_with("_price")
621 || normalized.ends_with("_value")
622 || normalized.ends_with("_bid")
623 || normalized.ends_with("_ask")
624 || normalized.ends_with("_open")
625 || normalized.ends_with("_high")
626 || normalized.ends_with("_low")
627 || normalized.ends_with("_close");
628
629 if is_price {
630 return metadata
631 .get(KEY_PRICE_PRECISION)
632 .and_then(|value| value.parse::<u8>().ok())
633 .map(|precision| (precision, true));
634 }
635
636 None
637}
638
639fn append_price(
640 builder: &mut Float64Builder,
641 values: &FixedPrecisionColumn<'_>,
642 precision: u8,
643 field: &'static str,
644 row: usize,
645) -> Result<(), EncodingError> {
646 let price = values.price(precision, field, row)?;
647 builder.append_value(price_to_f64(&price));
648 Ok(())
649}
650
651fn append_price_with_sentinel(
652 builder: &mut Float64Builder,
653 values: &FixedPrecisionColumn<'_>,
654 precision: u8,
655 field: &'static str,
656 row: usize,
657) -> Result<(), EncodingError> {
658 let price = values.price(precision, field, row)?;
659 builder.append_value(price_to_f64(&price));
660 Ok(())
661}
662
663fn append_optional_price_with_sentinel(
664 builder: &mut Float64Builder,
665 values: &FixedPrecisionColumn<'_>,
666 precision: u8,
667 field: &'static str,
668 row: usize,
669) -> Result<(), EncodingError> {
670 let price = values.price(precision, field, row)?;
671
672 if price.is_undefined() {
673 builder.append_null();
674 } else {
675 builder.append_value(price_to_f64(&price));
676 }
677 Ok(())
678}
679
680fn append_quantity(
681 builder: &mut Float64Builder,
682 values: &FixedPrecisionColumn<'_>,
683 precision: u8,
684 field: &'static str,
685 row: usize,
686) -> Result<(), EncodingError> {
687 let quantity = values.quantity(precision, field, row)?;
688 builder.append_value(quantity_to_f64(&quantity));
689 Ok(())
690}
691
692fn append_quantity_with_sentinel(
693 builder: &mut Float64Builder,
694 values: &FixedPrecisionColumn<'_>,
695 precision: u8,
696 field: &'static str,
697 row: usize,
698) -> Result<(), EncodingError> {
699 let quantity = values.quantity(precision, field, row)?;
700 builder.append_value(quantity_to_f64(&quantity));
701 Ok(())
702}
703
704fn append_optional_quantity_with_sentinel(
705 builder: &mut Float64Builder,
706 values: &FixedPrecisionColumn<'_>,
707 precision: u8,
708 field: &'static str,
709 row: usize,
710) -> Result<(), EncodingError> {
711 let quantity = values.quantity(precision, field, row)?;
712
713 if quantity.is_undefined() {
714 builder.append_null();
715 } else {
716 builder.append_value(quantity_to_f64(&quantity));
717 }
718 Ok(())
719}
720
721fn bars_schema() -> Schema {
722 Schema::new(vec![
723 utf8_field("instrument_id", false),
724 utf8_field("bar_type", false),
725 float64_field("open", false),
726 float64_field("high", false),
727 float64_field("low", false),
728 float64_field("close", false),
729 float64_field("volume", false),
730 timestamp_field("ts_event", false),
731 timestamp_field("ts_init", false),
732 ])
733}
734
735fn convert_bars(
736 metadata: &HashMap<String, String>,
737 batch: &RecordBatch,
738) -> Result<RecordBatch, EncodingError> {
739 let bar_type = bar_type(metadata)?;
740 let bar_type_string = bar_type.to_string();
741 let instrument_id_string = bar_type.instrument_id().to_string();
742 let price_precision = parse_price_precision(metadata)?;
743 let size_precision = parse_size_precision(metadata)?;
744 let open = fixed_col(batch, "open")?;
745 let high = fixed_col(batch, "high")?;
746 let low = fixed_col(batch, "low")?;
747 let close = fixed_col(batch, "close")?;
748 let volume = fixed_col(batch, "volume")?;
749 let ts_event = nanos_col(batch, "ts_event")?;
750 let ts_init = nanos_col(batch, "ts_init")?;
751
752 let len = batch.num_rows();
753 let mut open_builder = Float64Builder::with_capacity(len);
754 let mut high_builder = Float64Builder::with_capacity(len);
755 let mut low_builder = Float64Builder::with_capacity(len);
756 let mut close_builder = Float64Builder::with_capacity(len);
757 let mut volume_builder = Float64Builder::with_capacity(len);
758 let mut ts_event_builder =
759 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
760 let mut ts_init_builder =
761 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
762
763 for row in 0..len {
764 append_price(&mut open_builder, &open, price_precision, "open", row)?;
765 append_price(&mut high_builder, &high, price_precision, "high", row)?;
766 append_price(&mut low_builder, &low, price_precision, "low", row)?;
767 append_price(&mut close_builder, &close, price_precision, "close", row)?;
768 append_quantity(&mut volume_builder, &volume, size_precision, "volume", row)?;
769 append_timestamp(&mut ts_event_builder, &ts_event, row);
770 append_timestamp(&mut ts_init_builder, &ts_init, row);
771 }
772
773 RecordBatch::try_new(
774 Arc::new(bars_schema()),
775 vec![
776 constant_string_column(&instrument_id_string, len),
777 constant_string_column(&bar_type_string, len),
778 Arc::new(open_builder.finish()),
779 Arc::new(high_builder.finish()),
780 Arc::new(low_builder.finish()),
781 Arc::new(close_builder.finish()),
782 Arc::new(volume_builder.finish()),
783 Arc::new(ts_event_builder.finish()),
784 Arc::new(ts_init_builder.finish()),
785 ],
786 )
787 .map_err(EncodingError::from)
788}
789
790fn quotes_schema() -> Schema {
791 Schema::new(vec![
792 utf8_field("instrument_id", false),
793 float64_field("bid_price", false),
794 float64_field("ask_price", false),
795 float64_field("bid_size", false),
796 float64_field("ask_size", false),
797 timestamp_field("ts_event", false),
798 timestamp_field("ts_init", false),
799 ])
800}
801
802fn convert_quotes(
803 metadata: &HashMap<String, String>,
804 batch: &RecordBatch,
805) -> Result<RecordBatch, EncodingError> {
806 let instrument_id = instrument_id(metadata)?;
807 let price_precision = parse_price_precision(metadata)?;
808 let size_precision = parse_size_precision(metadata)?;
809 let bid_price = fixed_col(batch, "bid_price")?;
810 let ask_price = fixed_col(batch, "ask_price")?;
811 let bid_size = fixed_col(batch, "bid_size")?;
812 let ask_size = fixed_col(batch, "ask_size")?;
813 let ts_event = nanos_col(batch, "ts_event")?;
814 let ts_init = nanos_col(batch, "ts_init")?;
815
816 let len = batch.num_rows();
817 let mut bid_price_builder = Float64Builder::with_capacity(len);
818 let mut ask_price_builder = Float64Builder::with_capacity(len);
819 let mut bid_size_builder = Float64Builder::with_capacity(len);
820 let mut ask_size_builder = Float64Builder::with_capacity(len);
821 let mut ts_event_builder =
822 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
823 let mut ts_init_builder =
824 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
825
826 for row in 0..len {
827 append_price(
828 &mut bid_price_builder,
829 &bid_price,
830 price_precision,
831 "bid_price",
832 row,
833 )?;
834 append_price(
835 &mut ask_price_builder,
836 &ask_price,
837 price_precision,
838 "ask_price",
839 row,
840 )?;
841 append_quantity(
842 &mut bid_size_builder,
843 &bid_size,
844 size_precision,
845 "bid_size",
846 row,
847 )?;
848 append_quantity(
849 &mut ask_size_builder,
850 &ask_size,
851 size_precision,
852 "ask_size",
853 row,
854 )?;
855 append_timestamp(&mut ts_event_builder, &ts_event, row);
856 append_timestamp(&mut ts_init_builder, &ts_init, row);
857 }
858
859 RecordBatch::try_new(
860 Arc::new(quotes_schema()),
861 vec![
862 constant_string_column(instrument_id, len),
863 Arc::new(bid_price_builder.finish()),
864 Arc::new(ask_price_builder.finish()),
865 Arc::new(bid_size_builder.finish()),
866 Arc::new(ask_size_builder.finish()),
867 Arc::new(ts_event_builder.finish()),
868 Arc::new(ts_init_builder.finish()),
869 ],
870 )
871 .map_err(EncodingError::from)
872}
873
874fn trades_schema() -> Schema {
875 Schema::new(vec![
876 utf8_field("instrument_id", false),
877 float64_field("price", false),
878 float64_field("size", false),
879 utf8_field("aggressor_side", false),
880 utf8_field("trade_id", false),
881 timestamp_field("ts_event", false),
882 timestamp_field("ts_init", false),
883 ])
884}
885
886fn convert_trades(
887 metadata: &HashMap<String, String>,
888 batch: &RecordBatch,
889) -> Result<RecordBatch, EncodingError> {
890 let instrument_id = instrument_id(metadata)?;
891 let price_precision = parse_price_precision(metadata)?;
892 let size_precision = parse_size_precision(metadata)?;
893 let price = fixed_col(batch, "price")?;
894 let size = fixed_col(batch, "size")?;
895 let aggressor_side_index = batch.schema().index_of("aggressor_side")?;
896 let aggressor_side =
897 extract_column_string(batch.columns(), "aggressor_side", aggressor_side_index)?;
898 let trade_id_index = batch.schema().index_of("trade_id")?;
899 let trade_id = extract_column_string(batch.columns(), "trade_id", trade_id_index)?;
900 let ts_event = nanos_col(batch, "ts_event")?;
901 let ts_init = nanos_col(batch, "ts_init")?;
902
903 let len = batch.num_rows();
904 let mut price_builder = Float64Builder::with_capacity(len);
905 let mut size_builder = Float64Builder::with_capacity(len);
906 let mut aggressor_side_builder = StringBuilder::new();
907 let mut trade_id_builder = StringBuilder::new();
908 let mut ts_event_builder =
909 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
910 let mut ts_init_builder =
911 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
912
913 for row in 0..len {
914 let aggressor_side_value = aggressor_side.value(row);
915 let side = AggressorSide::from_str(aggressor_side_value)
916 .map_err(|e| EncodingError::ParseError(stringify!(AggressorSide), e.to_string()))?;
917 append_price(&mut price_builder, &price, price_precision, "price", row)?;
918 append_quantity(&mut size_builder, &size, size_precision, "size", row)?;
919 aggressor_side_builder.append_value(side.as_ref());
920 trade_id_builder.append_value(trade_id.value(row));
921 append_timestamp(&mut ts_event_builder, &ts_event, row);
922 append_timestamp(&mut ts_init_builder, &ts_init, row);
923 }
924
925 RecordBatch::try_new(
926 Arc::new(trades_schema()),
927 vec![
928 constant_string_column(instrument_id, len),
929 Arc::new(price_builder.finish()),
930 Arc::new(size_builder.finish()),
931 Arc::new(aggressor_side_builder.finish()),
932 Arc::new(trade_id_builder.finish()),
933 Arc::new(ts_event_builder.finish()),
934 Arc::new(ts_init_builder.finish()),
935 ],
936 )
937 .map_err(EncodingError::from)
938}
939
940fn deltas_schema() -> Schema {
941 Schema::new(vec![
942 utf8_field("instrument_id", false),
943 utf8_field("action", false),
944 utf8_field("side", false),
945 float64_field("price", false),
946 float64_field("size", false),
947 utf8_field("order_id", false),
948 Field::new("flags", DataType::UInt8, false),
949 Field::new("sequence", DataType::UInt64, false),
950 timestamp_field("ts_event", false),
951 timestamp_field("ts_init", false),
952 ])
953}
954
955fn convert_deltas(
956 metadata: &HashMap<String, String>,
957 batch: &RecordBatch,
958) -> Result<RecordBatch, EncodingError> {
959 let instrument_id = instrument_id(metadata)?;
960 let price_precision = parse_price_precision(metadata)?;
961 let size_precision = parse_size_precision(metadata)?;
962 let action_index = batch.schema().index_of("action")?;
963 let action = extract_column_string(batch.columns(), "action", action_index)?;
964 let side_index = batch.schema().index_of("side")?;
965 let side = extract_column_string(batch.columns(), "side", side_index)?;
966 let price = fixed_col(batch, "price")?;
967 let size = fixed_col(batch, "size")?;
968 let order_id = u64_col(batch, "order_id")?;
969 let flags = u8_col(batch, "flags")?;
970 let sequence = u64_col(batch, "sequence")?;
971 let ts_event = nanos_col(batch, "ts_event")?;
972 let ts_init = nanos_col(batch, "ts_init")?;
973
974 let len = batch.num_rows();
975 let mut action_builder = StringBuilder::new();
976 let mut side_builder = StringBuilder::new();
977 let mut price_builder = Float64Builder::with_capacity(len);
978 let mut size_builder = Float64Builder::with_capacity(len);
979 let mut order_id_builder = StringBuilder::new();
980 let mut flags_builder = UInt8Builder::with_capacity(len);
981 let mut sequence_builder = UInt64Builder::with_capacity(len);
982 let mut ts_event_builder =
983 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
984 let mut ts_init_builder =
985 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
986
987 for row in 0..len {
988 let action_value = action.value(row);
989 let action = BookAction::from_str(action_value)
990 .map_err(|e| EncodingError::ParseError(stringify!(BookAction), e.to_string()))?;
991 let side_value = side.value(row);
992 let side = OrderSide::from_str(side_value)
993 .map_err(|e| EncodingError::ParseError(stringify!(OrderSide), e.to_string()))?;
994
995 action_builder.append_value(action.as_ref());
996 side_builder.append_value(side.as_ref());
997 if action == BookAction::Clear {
998 price_builder.append_value(f64::NAN);
999 size_builder.append_value(f64::NAN);
1000 } else {
1001 append_price_with_sentinel(&mut price_builder, &price, price_precision, "price", row)?;
1002 append_quantity_with_sentinel(&mut size_builder, &size, size_precision, "size", row)?;
1003 }
1004 order_id_builder.append_value(u64_value(&order_id, row, "order_id")?.to_string());
1005 flags_builder.append_value(flags.value(row, "flags")?);
1006 sequence_builder.append_value(u64_value(&sequence, row, "sequence")?);
1007 append_timestamp(&mut ts_event_builder, &ts_event, row);
1008 append_timestamp(&mut ts_init_builder, &ts_init, row);
1009 }
1010
1011 RecordBatch::try_new(
1012 Arc::new(deltas_schema()),
1013 vec![
1014 constant_string_column(instrument_id, len),
1015 Arc::new(action_builder.finish()),
1016 Arc::new(side_builder.finish()),
1017 Arc::new(price_builder.finish()),
1018 Arc::new(size_builder.finish()),
1019 Arc::new(order_id_builder.finish()),
1020 Arc::new(flags_builder.finish()),
1021 Arc::new(sequence_builder.finish()),
1022 Arc::new(ts_event_builder.finish()),
1023 Arc::new(ts_init_builder.finish()),
1024 ],
1025 )
1026 .map_err(EncodingError::from)
1027}
1028
1029fn convert_depths(
1030 metadata: &HashMap<String, String>,
1031 batch: &RecordBatch,
1032) -> Result<RecordBatch, EncodingError> {
1033 let instrument_id = instrument_id(metadata)?;
1034 let price_precision = parse_price_precision(metadata)?;
1035 let size_precision = parse_size_precision(metadata)?;
1036 let bids = depth_struct_values(batch, "bids")?;
1037 let asks = depth_struct_values(batch, "asks")?;
1038 let flags = u8_col(batch, "flags")?;
1039 let sequence = u64_col(batch, "sequence")?;
1040 let ts_event = nanos_col(batch, "ts_event")?;
1041 let ts_init = nanos_col(batch, "ts_init")?;
1042 let len = batch.num_rows();
1043 let mut bids_display = DepthSideBuilder::new();
1044 let mut asks_display = DepthSideBuilder::new();
1045 let mut flags_builder = UInt8Builder::with_capacity(len);
1046 let mut sequence_builder = UInt64Builder::with_capacity(len);
1047 let mut ts_event_builder =
1048 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1049 let mut ts_init_builder =
1050 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1051 let bid_prices = FixedPrecisionColumn(&bids.prices);
1052 let ask_prices = FixedPrecisionColumn(&asks.prices);
1053 let bid_sizes = FixedPrecisionColumn(&bids.sizes);
1054 let ask_sizes = FixedPrecisionColumn(&asks.sizes);
1055
1056 for row in 0..len {
1057 let (bid_start, bid_end) = depth_struct_range(&bids.list, "bids", row)?;
1058 let (ask_start, ask_end) = depth_struct_range(&asks.list, "asks", row)?;
1059
1060 for value_index in bid_start..bid_end {
1061 append_optional_price_with_sentinel(
1062 &mut bids_display.prices,
1063 &bid_prices,
1064 price_precision,
1065 "bids.price",
1066 value_index,
1067 )?;
1068 append_optional_quantity_with_sentinel(
1069 &mut bids_display.sizes,
1070 &bid_sizes,
1071 size_precision,
1072 "bids.size",
1073 value_index,
1074 )?;
1075
1076 if bids.counts.is_null(value_index) || bids.order_ids.is_null(value_index) {
1077 return Err(EncodingError::ParseError(
1078 "bids",
1079 "count and order_id must not be null".to_string(),
1080 ));
1081 }
1082 bids_display
1083 .counts
1084 .append_value(bids.counts.value(value_index));
1085 bids_display
1086 .order_ids
1087 .append_value(bids.order_ids.value(value_index));
1088 }
1089
1090 for value_index in ask_start..ask_end {
1091 append_optional_price_with_sentinel(
1092 &mut asks_display.prices,
1093 &ask_prices,
1094 price_precision,
1095 "asks.price",
1096 value_index,
1097 )?;
1098 append_optional_quantity_with_sentinel(
1099 &mut asks_display.sizes,
1100 &ask_sizes,
1101 size_precision,
1102 "asks.size",
1103 value_index,
1104 )?;
1105
1106 if asks.counts.is_null(value_index) || asks.order_ids.is_null(value_index) {
1107 return Err(EncodingError::ParseError(
1108 "asks",
1109 "count and order_id must not be null".to_string(),
1110 ));
1111 }
1112 asks_display
1113 .counts
1114 .append_value(asks.counts.value(value_index));
1115 asks_display
1116 .order_ids
1117 .append_value(asks.order_ids.value(value_index));
1118 }
1119 bids_display.finish_row()?;
1120 asks_display.finish_row()?;
1121 flags_builder.append_value(flags.value(row, "flags")?);
1122 sequence_builder.append_value(u64_value(&sequence, row, "sequence")?);
1123 append_timestamp(&mut ts_event_builder, &ts_event, row);
1124 append_timestamp(&mut ts_init_builder, &ts_init, row);
1125 }
1126
1127 finish_depth_display(
1128 instrument_id,
1129 len,
1130 bids_display,
1131 asks_display,
1132 flags_builder,
1133 sequence_builder,
1134 ts_event_builder,
1135 ts_init_builder,
1136 )
1137}
1138
1139#[expect(
1140 clippy::too_many_arguments,
1141 reason = "the two nested sides and four scalar builders form one batch"
1142)]
1143fn finish_depth_display(
1144 instrument_id: &str,
1145 len: usize,
1146 bids: DepthSideBuilder,
1147 asks: DepthSideBuilder,
1148 mut flags: UInt8Builder,
1149 mut sequence: UInt64Builder,
1150 mut ts_event: TimestampNanosecondBuilder,
1151 mut ts_init: TimestampNanosecondBuilder,
1152) -> Result<RecordBatch, EncodingError> {
1153 RecordBatch::try_new(
1154 Arc::new(depth_schema()),
1155 vec![
1156 constant_string_column(instrument_id, len),
1157 Arc::new(bids.finish()?),
1158 Arc::new(asks.finish()?),
1159 Arc::new(flags.finish()),
1160 Arc::new(sequence.finish()),
1161 Arc::new(ts_event.finish()),
1162 Arc::new(ts_init.finish()),
1163 ],
1164 )
1165 .map_err(EncodingError::from)
1166}
1167
1168struct DepthStructColumns {
1169 list: ListArray,
1170 prices: Decimal128Array,
1171 sizes: Decimal128Array,
1172 counts: UInt32Array,
1173 order_ids: UInt64Array,
1174}
1175
1176fn depth_struct_values(
1177 batch: &RecordBatch,
1178 name: &'static str,
1179) -> Result<DepthStructColumns, EncodingError> {
1180 let list = batch
1181 .column_by_name(name)
1182 .and_then(|column| column.as_any().downcast_ref::<ListArray>())
1183 .cloned()
1184 .ok_or_else(|| EncodingError::ParseError(name, "expected List<Struct>".to_string()))?;
1185 let levels = list
1186 .values()
1187 .as_any()
1188 .downcast_ref::<StructArray>()
1189 .ok_or_else(|| EncodingError::ParseError(name, "expected List<Struct>".to_string()))?;
1190 let child = |field: &'static str| {
1191 levels.column_by_name(field).cloned().ok_or_else(|| {
1192 EncodingError::ParseError(name, format!("missing struct field '{field}'"))
1193 })
1194 };
1195
1196 for field in ["price", "size"] {
1197 if child(field)?.data_type() != &fixed_decimal_data_type() {
1198 return Err(EncodingError::ParseError(
1199 name,
1200 format!("field '{field}' must use Decimal128(38, 16)"),
1201 ));
1202 }
1203 }
1204 let prices = child("price")?
1205 .as_any()
1206 .downcast_ref::<Decimal128Array>()
1207 .cloned()
1208 .ok_or_else(|| {
1209 EncodingError::ParseError(name, "field 'price' must be Decimal128".to_string())
1210 })?;
1211 let sizes = child("size")?
1212 .as_any()
1213 .downcast_ref::<Decimal128Array>()
1214 .cloned()
1215 .ok_or_else(|| {
1216 EncodingError::ParseError(name, "field 'size' must be Decimal128".to_string())
1217 })?;
1218 let counts = child("count")?
1219 .as_any()
1220 .downcast_ref::<UInt32Array>()
1221 .cloned()
1222 .ok_or_else(|| {
1223 EncodingError::ParseError(name, "field 'count' must be UInt32".to_string())
1224 })?;
1225 let order_ids = child("order_id")?
1226 .as_any()
1227 .downcast_ref::<UInt64Array>()
1228 .cloned()
1229 .ok_or_else(|| {
1230 EncodingError::ParseError(name, "field 'order_id' must be UInt64".to_string())
1231 })?;
1232 Ok(DepthStructColumns {
1233 list,
1234 prices,
1235 sizes,
1236 counts,
1237 order_ids,
1238 })
1239}
1240
1241fn depth_struct_range(
1242 list: &ListArray,
1243 name: &'static str,
1244 row: usize,
1245) -> Result<(usize, usize), EncodingError> {
1246 if list.is_null(row) {
1247 return Err(EncodingError::ParseError(
1248 name,
1249 format!("side is null at row {row}"),
1250 ));
1251 }
1252 let offsets = list.value_offsets();
1253 let start = usize::try_from(offsets[row])
1254 .map_err(|e| EncodingError::ParseError(name, e.to_string()))?;
1255 let end = usize::try_from(offsets[row + 1])
1256 .map_err(|e| EncodingError::ParseError(name, e.to_string()))?;
1257 Ok((start, end))
1258}
1259
1260fn price_update_schema() -> Schema {
1261 Schema::new(vec![
1262 utf8_field("instrument_id", false),
1263 float64_field("value", false),
1264 timestamp_field("ts_event", false),
1265 timestamp_field("ts_init", false),
1266 ])
1267}
1268
1269fn convert_price_updates(
1270 metadata: &HashMap<String, String>,
1271 batch: &RecordBatch,
1272) -> Result<RecordBatch, EncodingError> {
1273 let instrument_id = instrument_id(metadata)?;
1274 let price_precision = parse_price_precision(metadata)?;
1275 let value = fixed_col(batch, "value")?;
1276 let ts_event = nanos_col(batch, "ts_event")?;
1277 let ts_init = nanos_col(batch, "ts_init")?;
1278 let len = batch.num_rows();
1279 let mut value_builder = Float64Builder::with_capacity(len);
1280 let mut ts_event_builder =
1281 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1282 let mut ts_init_builder =
1283 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1284
1285 for row in 0..len {
1286 append_price(&mut value_builder, &value, price_precision, "value", row)?;
1287 append_timestamp(&mut ts_event_builder, &ts_event, row);
1288 append_timestamp(&mut ts_init_builder, &ts_init, row);
1289 }
1290
1291 RecordBatch::try_new(
1292 Arc::new(price_update_schema()),
1293 vec![
1294 constant_string_column(instrument_id, len),
1295 Arc::new(value_builder.finish()),
1296 Arc::new(ts_event_builder.finish()),
1297 Arc::new(ts_init_builder.finish()),
1298 ],
1299 )
1300 .map_err(EncodingError::from)
1301}
1302
1303fn funding_rate_schema() -> Schema {
1304 Schema::new(vec![
1305 utf8_field("instrument_id", false),
1306 float64_field("rate", false),
1307 Field::new("interval", DataType::UInt64, true),
1308 timestamp_field("next_funding_ns", true),
1309 timestamp_field("ts_event", false),
1310 timestamp_field("ts_init", false),
1311 ])
1312}
1313
1314fn convert_funding_rates(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1315 let instrument_id_index = batch.schema().index_of("instrument_id")?;
1316 let instrument_id =
1317 extract_column_string(batch.columns(), "instrument_id", instrument_id_index)?;
1318 let rate_index = batch.schema().index_of("rate")?;
1319 let rate = extract_column_string(batch.columns(), "rate", rate_index)?;
1320 let interval = u64_col(batch, "interval")?;
1321 let next_funding_ns = nanos_col(batch, "next_funding_ns")?;
1322 let ts_event = nanos_col(batch, "ts_event")?;
1323 let ts_init = nanos_col(batch, "ts_init")?;
1324 let len = batch.num_rows();
1325
1326 let mut instrument_id_builder = StringBuilder::new();
1327 let mut rate_builder = Float64Builder::with_capacity(len);
1328 let mut interval_builder = UInt64Builder::with_capacity(len);
1329 let mut next_funding_ns_builder =
1330 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1331 let mut ts_event_builder =
1332 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1333 let mut ts_init_builder =
1334 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1335
1336 for row in 0..len {
1337 instrument_id_builder.append_value(instrument_id.value(row));
1338 let value = Decimal::from_str(rate.value(row))
1339 .map_err(|e| EncodingError::ParseError("rate", e.to_string()))?;
1340 rate_builder.append_value(value.to_f64().unwrap_or(f64::NAN));
1341 append_optional_u64(&mut interval_builder, &interval, "interval", row)?;
1342 append_timestamp(&mut next_funding_ns_builder, &next_funding_ns, row);
1343 append_timestamp(&mut ts_event_builder, &ts_event, row);
1344 append_timestamp(&mut ts_init_builder, &ts_init, row);
1345 }
1346
1347 RecordBatch::try_new(
1348 Arc::new(funding_rate_schema()),
1349 vec![
1350 Arc::new(instrument_id_builder.finish()),
1351 Arc::new(rate_builder.finish()),
1352 Arc::new(interval_builder.finish()),
1353 Arc::new(next_funding_ns_builder.finish()),
1354 Arc::new(ts_event_builder.finish()),
1355 Arc::new(ts_init_builder.finish()),
1356 ],
1357 )
1358 .map_err(EncodingError::from)
1359}
1360
1361fn instrument_closes_schema() -> Schema {
1362 Schema::new(vec![
1363 utf8_field("instrument_id", false),
1364 float64_field("close_price", false),
1365 utf8_field("close_type", false),
1366 timestamp_field("ts_event", false),
1367 timestamp_field("ts_init", false),
1368 ])
1369}
1370
1371fn convert_instrument_closes(
1372 metadata: &HashMap<String, String>,
1373 batch: &RecordBatch,
1374) -> Result<RecordBatch, EncodingError> {
1375 let instrument_id = instrument_id(metadata)?;
1376 let price_precision = parse_price_precision(metadata)?;
1377 let close_price = fixed_col(batch, "close_price")?;
1378 let close_type_index = batch.schema().index_of("close_type")?;
1379 let close_type = extract_column_string(batch.columns(), "close_type", close_type_index)?;
1380 let ts_event = nanos_col(batch, "ts_event")?;
1381 let ts_init = nanos_col(batch, "ts_init")?;
1382 let len = batch.num_rows();
1383 let mut close_price_builder = Float64Builder::with_capacity(len);
1384 let mut close_type_builder = StringBuilder::new();
1385 let mut ts_event_builder =
1386 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1387 let mut ts_init_builder =
1388 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1389
1390 for row in 0..len {
1391 let close_type_value = close_type.value(row);
1392 let close_type = InstrumentCloseType::from_str(close_type_value).map_err(|e| {
1393 EncodingError::ParseError(stringify!(InstrumentCloseType), e.to_string())
1394 })?;
1395 append_price(
1396 &mut close_price_builder,
1397 &close_price,
1398 price_precision,
1399 "close_price",
1400 row,
1401 )?;
1402 close_type_builder.append_value(close_type.as_ref());
1403 append_timestamp(&mut ts_event_builder, &ts_event, row);
1404 append_timestamp(&mut ts_init_builder, &ts_init, row);
1405 }
1406
1407 RecordBatch::try_new(
1408 Arc::new(instrument_closes_schema()),
1409 vec![
1410 constant_string_column(instrument_id, len),
1411 Arc::new(close_price_builder.finish()),
1412 Arc::new(close_type_builder.finish()),
1413 Arc::new(ts_event_builder.finish()),
1414 Arc::new(ts_init_builder.finish()),
1415 ],
1416 )
1417 .map_err(EncodingError::from)
1418}
1419
1420fn option_greeks_schema() -> Schema {
1421 Schema::new(vec![
1422 utf8_field("instrument_id", false),
1423 float64_field("delta", false),
1424 float64_field("gamma", false),
1425 float64_field("vega", false),
1426 float64_field("theta", false),
1427 float64_field("rho", false),
1428 float64_field("mark_iv", true),
1429 float64_field("bid_iv", true),
1430 float64_field("ask_iv", true),
1431 float64_field("underlying_price", true),
1432 float64_field("open_interest", true),
1433 timestamp_field("ts_event", false),
1434 timestamp_field("ts_init", false),
1435 utf8_field("convention", false),
1436 ])
1437}
1438
1439fn append_optional_f64(builder: &mut Float64Builder, values: &Float64Array, row: usize) {
1440 if values.is_null(row) {
1441 builder.append_null();
1442 } else {
1443 builder.append_value(values.value(row));
1444 }
1445}
1446
1447fn convert_option_greeks(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1448 let instrument_id_index = batch.schema().index_of("instrument_id")?;
1449 let instrument_id =
1450 extract_column_string(batch.columns(), "instrument_id", instrument_id_index)?;
1451 let delta = f64_col(batch, "delta")?;
1452 let gamma = f64_col(batch, "gamma")?;
1453 let vega = f64_col(batch, "vega")?;
1454 let theta = f64_col(batch, "theta")?;
1455 let rho = f64_col(batch, "rho")?;
1456 let mark_iv = f64_col(batch, "mark_iv")?;
1457 let bid_iv = f64_col(batch, "bid_iv")?;
1458 let ask_iv = f64_col(batch, "ask_iv")?;
1459 let underlying_price = f64_col(batch, "underlying_price")?;
1460 let open_interest = f64_col(batch, "open_interest")?;
1461 let ts_event = nanos_col(batch, "ts_event")?;
1462 let ts_init = nanos_col(batch, "ts_init")?;
1463 let convention_index = batch.schema().index_of("convention")?;
1464 let convention = extract_column_string(batch.columns(), "convention", convention_index)?;
1465 let len = batch.num_rows();
1466
1467 let mut instrument_id_builder = StringBuilder::new();
1468 let mut delta_builder = Float64Builder::with_capacity(len);
1469 let mut gamma_builder = Float64Builder::with_capacity(len);
1470 let mut vega_builder = Float64Builder::with_capacity(len);
1471 let mut theta_builder = Float64Builder::with_capacity(len);
1472 let mut rho_builder = Float64Builder::with_capacity(len);
1473 let mut mark_iv_builder = Float64Builder::with_capacity(len);
1474 let mut bid_iv_builder = Float64Builder::with_capacity(len);
1475 let mut ask_iv_builder = Float64Builder::with_capacity(len);
1476 let mut underlying_price_builder = Float64Builder::with_capacity(len);
1477 let mut open_interest_builder = Float64Builder::with_capacity(len);
1478 let mut ts_event_builder =
1479 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1480 let mut ts_init_builder =
1481 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1482 let mut convention_builder = StringBuilder::new();
1483
1484 for row in 0..len {
1485 instrument_id_builder.append_value(instrument_id.value(row));
1486 delta_builder.append_value(delta.value(row));
1487 gamma_builder.append_value(gamma.value(row));
1488 vega_builder.append_value(vega.value(row));
1489 theta_builder.append_value(theta.value(row));
1490 rho_builder.append_value(rho.value(row));
1491 append_optional_f64(&mut mark_iv_builder, mark_iv, row);
1492 append_optional_f64(&mut bid_iv_builder, bid_iv, row);
1493 append_optional_f64(&mut ask_iv_builder, ask_iv, row);
1494 append_optional_f64(&mut underlying_price_builder, underlying_price, row);
1495 append_optional_f64(&mut open_interest_builder, open_interest, row);
1496 append_timestamp(&mut ts_event_builder, &ts_event, row);
1497 append_timestamp(&mut ts_init_builder, &ts_init, row);
1498 convention_builder.append_value(convention.value(row));
1499 }
1500
1501 RecordBatch::try_new(
1502 Arc::new(option_greeks_schema()),
1503 vec![
1504 Arc::new(instrument_id_builder.finish()),
1505 Arc::new(delta_builder.finish()),
1506 Arc::new(gamma_builder.finish()),
1507 Arc::new(vega_builder.finish()),
1508 Arc::new(theta_builder.finish()),
1509 Arc::new(rho_builder.finish()),
1510 Arc::new(mark_iv_builder.finish()),
1511 Arc::new(bid_iv_builder.finish()),
1512 Arc::new(ask_iv_builder.finish()),
1513 Arc::new(underlying_price_builder.finish()),
1514 Arc::new(open_interest_builder.finish()),
1515 Arc::new(ts_event_builder.finish()),
1516 Arc::new(ts_init_builder.finish()),
1517 Arc::new(convention_builder.finish()),
1518 ],
1519 )
1520 .map_err(EncodingError::from)
1521}
1522
1523fn instrument_status_schema() -> Schema {
1524 Schema::new(vec![
1525 utf8_field("instrument_id", false),
1526 utf8_field("action", false),
1527 timestamp_field("ts_event", false),
1528 timestamp_field("ts_init", false),
1529 utf8_field("reason", true),
1530 utf8_field("trading_event", true),
1531 Field::new("is_trading", DataType::Boolean, true),
1532 Field::new("is_quoting", DataType::Boolean, true),
1533 Field::new("is_short_sell_restricted", DataType::Boolean, true),
1534 ])
1535}
1536
1537fn append_optional_string(
1538 builder: &mut StringBuilder,
1539 values: &super::StringColumnRef<'_>,
1540 row: usize,
1541 array: &dyn Array,
1542) {
1543 if array.is_null(row) {
1544 builder.append_null();
1545 } else {
1546 builder.append_value(values.value(row));
1547 }
1548}
1549
1550fn append_optional_bool(
1551 builder: &mut arrow::array::BooleanBuilder,
1552 values: &BooleanArray,
1553 row: usize,
1554) {
1555 if values.is_null(row) {
1556 builder.append_null();
1557 } else {
1558 builder.append_value(values.value(row));
1559 }
1560}
1561
1562fn append_optional_u64(
1563 builder: &mut UInt64Builder,
1564 values: &U64ColumnRef<'_>,
1565 field: &'static str,
1566 row: usize,
1567) -> Result<(), EncodingError> {
1568 if values.is_null(row) {
1569 builder.append_null();
1570 } else {
1571 builder.append_value(u64_value(values, row, field)?);
1572 }
1573 Ok(())
1574}
1575
1576fn convert_instrument_status(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1577 let instrument_id_index = batch.schema().index_of("instrument_id")?;
1578 let instrument_id =
1579 extract_column_string(batch.columns(), "instrument_id", instrument_id_index)?;
1580 let action_index = batch.schema().index_of("action")?;
1581 let action = extract_column_string(batch.columns(), "action", action_index)?;
1582 let ts_event = nanos_col(batch, "ts_event")?;
1583 let ts_init = nanos_col(batch, "ts_init")?;
1584 let reason_index = batch.schema().index_of("reason")?;
1585 let reason = extract_column_string(batch.columns(), "reason", reason_index)?;
1586 let trading_event_index = batch.schema().index_of("trading_event")?;
1587 let trading_event =
1588 extract_column_string(batch.columns(), "trading_event", trading_event_index)?;
1589 let is_trading = bool_col(batch, "is_trading")?;
1590 let is_quoting = bool_col(batch, "is_quoting")?;
1591 let is_short_sell_restricted = bool_col(batch, "is_short_sell_restricted")?;
1592 let len = batch.num_rows();
1593
1594 let mut instrument_id_builder = StringBuilder::new();
1595 let mut action_builder = StringBuilder::new();
1596 let mut ts_event_builder =
1597 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1598 let mut ts_init_builder =
1599 TimestampNanosecondBuilder::with_capacity(len).with_data_type(timestamp_data_type());
1600 let mut reason_builder = StringBuilder::new();
1601 let mut trading_event_builder = StringBuilder::new();
1602 let mut is_trading_builder = arrow::array::BooleanBuilder::with_capacity(len);
1603 let mut is_quoting_builder = arrow::array::BooleanBuilder::with_capacity(len);
1604 let mut is_short_sell_restricted_builder = arrow::array::BooleanBuilder::with_capacity(len);
1605
1606 for row in 0..len {
1607 instrument_id_builder.append_value(instrument_id.value(row));
1608 action_builder.append_value(action.value(row));
1609 append_timestamp(&mut ts_event_builder, &ts_event, row);
1610 append_timestamp(&mut ts_init_builder, &ts_init, row);
1611 append_optional_string(
1612 &mut reason_builder,
1613 &reason,
1614 row,
1615 batch.column(reason_index).as_ref(),
1616 );
1617 append_optional_string(
1618 &mut trading_event_builder,
1619 &trading_event,
1620 row,
1621 batch.column(trading_event_index).as_ref(),
1622 );
1623 append_optional_bool(&mut is_trading_builder, is_trading, row);
1624 append_optional_bool(&mut is_quoting_builder, is_quoting, row);
1625 append_optional_bool(
1626 &mut is_short_sell_restricted_builder,
1627 is_short_sell_restricted,
1628 row,
1629 );
1630 }
1631
1632 RecordBatch::try_new(
1633 Arc::new(instrument_status_schema()),
1634 vec![
1635 Arc::new(instrument_id_builder.finish()),
1636 Arc::new(action_builder.finish()),
1637 Arc::new(ts_event_builder.finish()),
1638 Arc::new(ts_init_builder.finish()),
1639 Arc::new(reason_builder.finish()),
1640 Arc::new(trading_event_builder.finish()),
1641 Arc::new(is_trading_builder.finish()),
1642 Arc::new(is_quoting_builder.finish()),
1643 Arc::new(is_short_sell_restricted_builder.finish()),
1644 ],
1645 )
1646 .map_err(EncodingError::from)
1647}
1648
1649fn convert_custom(batch: &RecordBatch) -> Result<RecordBatch, EncodingError> {
1654 let batch_schema = batch.schema();
1655 let metadata = batch_schema.metadata();
1656 let mut fields = Vec::with_capacity(batch.num_columns());
1657 let mut columns = Vec::with_capacity(batch.num_columns());
1658
1659 for (field, column) in batch_schema.fields().iter().zip(batch.columns()) {
1660 if matches!(field.name().as_str(), "ts_event" | "ts_init") {
1661 let name = if field.name() == "ts_event" {
1662 "ts_event"
1663 } else {
1664 "ts_init"
1665 };
1666 let values = nanos_col(batch, name)?;
1667 fields.push(Arc::new(timestamp_field_from(field)));
1668 columns.push(timestamp_array_from_nanos(&values));
1669 } else if let Some((precision, is_price)) = custom_fixed_precision(field.name(), metadata)
1670 && matches!(
1671 field.data_type(),
1672 DataType::Decimal128(_, _)
1673 | DataType::FixedSizeBinary(_)
1674 | DataType::Binary
1675 | DataType::BinaryView
1676 )
1677 {
1678 if field.data_type() != &fixed_decimal_data_type() {
1679 return Err(EncodingError::ParseError(
1680 "custom",
1681 format!(
1682 "{} must use Decimal128(38, 16); migrate legacy data before display",
1683 field.name()
1684 ),
1685 ));
1686 }
1687 let values = FixedPrecisionColumn(
1688 column
1689 .as_any()
1690 .downcast_ref::<Decimal128Array>()
1691 .expect("decimal field type checked above"),
1692 );
1693 fields.push(Arc::new(Field::new(
1694 field.name(),
1695 DataType::Float64,
1696 field.is_nullable(),
1697 )));
1698 columns.push(fixed_precision_field_to_f64(&values, precision, is_price)?);
1699 } else if field.data_type() == &DataType::Utf8View {
1700 fields.push(Arc::new(Field::new(
1701 field.name(),
1702 DataType::Utf8,
1703 field.is_nullable(),
1704 )));
1705 columns.push(cast(column.as_ref(), &DataType::Utf8)?);
1706 } else {
1707 fields.push(field.clone());
1708 columns.push(column.clone());
1709 }
1710 }
1711
1712 let metadata = if let Some(type_name) = metadata.get("type_name")
1713 && get_arrow_schema(type_name).is_some()
1714 {
1715 let canonical_metadata = HashMap::from([("type_name".to_string(), type_name.clone())]);
1716 let canonical_schema = super::schema_with_identifier_column(
1717 &CustomDataDecoder::get_schema(Some(canonical_metadata)),
1718 );
1719
1720 for field in &mut fields {
1721 if let Ok(canonical_field) = canonical_schema.field_with_name(field.name()) {
1722 *field = Arc::new(
1723 field
1724 .as_ref()
1725 .clone()
1726 .with_nullable(canonical_field.is_nullable()),
1727 );
1728 }
1729 }
1730 canonical_schema.metadata().clone()
1731 } else {
1732 batch_schema.metadata().clone()
1733 };
1734 let schema = Schema::new_with_metadata(fields, metadata);
1735 RecordBatch::try_new(Arc::new(schema), columns).map_err(EncodingError::from)
1736}
1737
1738#[cfg(test)]
1739mod tests {
1740 use std::{collections::HashMap, str::FromStr};
1741
1742 use arrow::{
1743 array::{
1744 Array, FixedSizeBinaryArray, FixedSizeListArray, Float64Array, StringArray,
1745 StringViewArray, UInt64Array,
1746 },
1747 datatypes::{Field, TimeUnit},
1748 };
1749 use nautilus_core::UnixNanos;
1750 #[cfg(feature = "arrow-display")]
1751 use nautilus_model::data::stubs::{
1752 stub_bar, stub_delta, stub_instrument_close, stub_trade_ethusdt_buy,
1753 };
1754 use nautilus_model::{
1755 data::{
1756 FundingRateUpdate, OrderBookDepth, QuoteTick, ensure_arrow_registered,
1757 stubs::stub_depth10,
1758 },
1759 identifiers::InstrumentId,
1760 types::{Price, Quantity},
1761 };
1762 use rstest::rstest;
1763 use rust_decimal::Decimal;
1764
1765 use super::*;
1766 use crate::arrow::{
1767 EncodeToRecordBatch, KEY_INSTRUMENT_ID, KEY_PRICE_PRECISION, KEY_SIZE_PRECISION,
1768 };
1769 #[cfg(feature = "arrow-display")]
1770 use crate::arrow::{
1771 display::{
1772 bar::encode_bars, close::encode_instrument_closes, delta::encode_deltas,
1773 trade::encode_trades,
1774 },
1775 record_batch_without_identifier_column,
1776 };
1777
1778 #[rstest]
1779 fn test_catalog_record_batch_to_display_converts_quote_prices_and_metadata() {
1780 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1781 let metadata = HashMap::from([
1782 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
1783 (KEY_PRICE_PRECISION.to_string(), "5".to_string()),
1784 (KEY_SIZE_PRECISION.to_string(), "0".to_string()),
1785 ]);
1786 let quote = QuoteTick {
1787 instrument_id,
1788 bid_price: Price::from("1.00001"),
1789 ask_price: Price::from("1.00003"),
1790 bid_size: Quantity::from(1_000),
1791 ask_size: Quantity::from(2_000),
1792 ts_event: 10.into(),
1793 ts_init: 11.into(),
1794 };
1795 let raw_batch = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
1796
1797 let display_batch =
1798 catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &raw_batch)
1799 .unwrap();
1800
1801 let mut expected_fields = quotes_schema().fields().to_vec();
1802 expected_fields.push(Arc::new(utf8_field(KEY_IDENTIFIER, true)));
1803 assert_eq!(
1804 display_batch.schema(),
1805 Arc::new(Schema::new(expected_fields))
1806 );
1807 let instrument_ids = display_batch
1808 .column(0)
1809 .as_any()
1810 .downcast_ref::<StringArray>()
1811 .unwrap();
1812 let bid_prices = display_batch
1813 .column(1)
1814 .as_any()
1815 .downcast_ref::<Float64Array>()
1816 .unwrap();
1817 let ask_sizes = display_batch
1818 .column(4)
1819 .as_any()
1820 .downcast_ref::<Float64Array>()
1821 .unwrap();
1822 let ts_init = display_batch
1823 .column(6)
1824 .as_any()
1825 .downcast_ref::<arrow::array::TimestampNanosecondArray>()
1826 .unwrap();
1827 let identifiers = display_batch
1828 .column(7)
1829 .as_any()
1830 .downcast_ref::<StringArray>()
1831 .unwrap();
1832
1833 assert_eq!(instrument_ids.value(0), "AUD/USD.SIM");
1834 assert_eq!(bid_prices.value(0), 1.00001);
1835 assert_eq!(ask_sizes.value(0), 2_000.0);
1836 assert_eq!(ts_init.value(0), 11);
1837 assert_eq!(identifiers.value(0), "AUD/USD.SIM");
1838 }
1839
1840 #[rstest]
1841 #[case(1)]
1842 #[case(u64::MAX)]
1843 fn test_catalog_record_batch_to_display_rejects_legacy_timestamps(#[case] timestamp: u64) {
1844 let instrument_id = InstrumentId::from("AUD/USD.SIM");
1845 let metadata = HashMap::from([
1846 (KEY_INSTRUMENT_ID.to_string(), instrument_id.to_string()),
1847 (KEY_PRICE_PRECISION.to_string(), "5".to_string()),
1848 (KEY_SIZE_PRECISION.to_string(), "0".to_string()),
1849 ]);
1850 let quote = QuoteTick {
1851 instrument_id,
1852 bid_price: Price::from("1.00001"),
1853 ask_price: Price::from("1.00003"),
1854 bid_size: Quantity::from(1_000),
1855 ask_size: Quantity::from(2_000),
1856 ts_event: 10.into(),
1857 ts_init: 11.into(),
1858 };
1859 let raw_batch = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
1860 let mut fields = raw_batch.schema().fields().to_vec();
1861 let ts_init_index = raw_batch.schema().index_of("ts_init").unwrap();
1862 fields[ts_init_index] = Arc::new(Field::new("ts_init", DataType::UInt64, false));
1863 let mut columns = raw_batch.columns().to_vec();
1864 columns[ts_init_index] = Arc::new(UInt64Array::from(vec![timestamp]));
1865 let raw_batch = RecordBatch::try_new(
1866 Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
1867 columns,
1868 )
1869 .unwrap();
1870
1871 let error =
1872 catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &raw_batch)
1873 .unwrap_err();
1874
1875 assert!(matches!(
1876 error,
1877 EncodingError::InvalidColumnType(
1878 "ts_init",
1879 _,
1880 DataType::Timestamp(TimeUnit::Nanosecond, Some(timezone)),
1881 DataType::UInt64
1882 ) if timezone.as_ref() == "UTC"
1883 ));
1884 }
1885
1886 #[rstest]
1887 #[case(38, 15)]
1888 #[case(37, 16)]
1889 fn test_catalog_display_rejects_noncanonical_decimal_schema(
1890 #[case] precision: u8,
1891 #[case] scale: i8,
1892 ) {
1893 let quote = QuoteTick::new(
1894 InstrumentId::from("AUD/USD.SIM"),
1895 Price::from("1.25"),
1896 Price::from("1.50"),
1897 Quantity::from(10),
1898 Quantity::from(20),
1899 10.into(),
1900 11.into(),
1901 );
1902 let metadata = quote.metadata();
1903 let raw = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
1904 let index = raw.schema().index_of("bid_price").unwrap();
1905 let mut fields = raw.schema().fields().to_vec();
1906 let mut columns = raw.columns().to_vec();
1907 let values = columns[index]
1908 .as_any()
1909 .downcast_ref::<Decimal128Array>()
1910 .unwrap()
1911 .clone()
1912 .with_precision_and_scale(precision, scale)
1913 .unwrap();
1914 fields[index] = Arc::new(
1915 fields[index]
1916 .as_ref()
1917 .clone()
1918 .with_data_type(values.data_type().clone()),
1919 );
1920 columns[index] = Arc::new(values);
1921 let batch = RecordBatch::try_new(
1922 Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
1923 columns,
1924 )
1925 .unwrap();
1926 let error =
1927 catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &batch)
1928 .unwrap_err();
1929
1930 assert!(
1931 matches!(error, EncodingError::InvalidColumnType("bid_price", i, DataType::Decimal128(38, 16), actual) if i == index && actual == DataType::Decimal128(precision, scale))
1932 );
1933 }
1934
1935 #[rstest]
1936 #[case(0)]
1937 #[case(5)]
1938 #[case(10)]
1939 #[case(25)]
1940 fn test_depth_display_preserves_all_levels(#[case] levels: usize) {
1941 let mut depth = stub_depth10();
1942 depth.bids.resize(levels, depth.bids[0]);
1943 depth.bid_counts.resize(levels, 0);
1944 depth.asks.clear();
1945 depth.ask_counts.clear();
1946 for (i, (order, count)) in depth.bids.iter_mut().zip(&mut depth.bid_counts).enumerate() {
1947 order.price = format!("{}.25", 100 + i).parse().unwrap();
1948 order.size = format!("{}.5", 200 + i).parse().unwrap();
1949 order.order_id = u64::MAX - u64::try_from(i).unwrap();
1950 *count = 300 + u32::try_from(i).unwrap();
1951 }
1952 let metadata = depth.metadata();
1953 let raw = OrderBookDepth::encode_batch(&metadata, &[depth]).unwrap();
1954 let display =
1955 catalog_record_batch_to_display(&NautilusDataType::OrderBookDepth, &metadata, &raw)
1956 .unwrap();
1957 let list = display
1958 .column_by_name("bids")
1959 .unwrap()
1960 .as_any()
1961 .downcast_ref::<ListArray>()
1962 .unwrap();
1963 let values = list.value(0);
1964 let structs = values.as_any().downcast_ref::<StructArray>().unwrap();
1965 let prices = structs
1966 .column_by_name("price")
1967 .unwrap()
1968 .as_any()
1969 .downcast_ref::<Float64Array>()
1970 .unwrap();
1971 let sizes = structs
1972 .column_by_name("size")
1973 .unwrap()
1974 .as_any()
1975 .downcast_ref::<Float64Array>()
1976 .unwrap();
1977 let counts = structs
1978 .column_by_name("count")
1979 .unwrap()
1980 .as_any()
1981 .downcast_ref::<UInt32Array>()
1982 .unwrap();
1983 let ids = structs
1984 .column_by_name("order_id")
1985 .unwrap()
1986 .as_any()
1987 .downcast_ref::<UInt64Array>()
1988 .unwrap();
1989 let asks = display
1990 .column_by_name("asks")
1991 .unwrap()
1992 .as_any()
1993 .downcast_ref::<ListArray>()
1994 .unwrap();
1995 let empty = catalog_record_batch_to_display(
1996 &NautilusDataType::OrderBookDepth,
1997 &metadata,
1998 &raw.slice(0, 0),
1999 )
2000 .unwrap();
2001
2002 assert_eq!(
2003 display.schema().as_ref(),
2004 &crate::arrow::schema_with_identifier_column(&depth_schema())
2005 );
2006 assert_eq!(empty.schema(), display.schema());
2007 assert_eq!(empty.num_rows(), 0);
2008 assert_eq!(display.num_rows(), 1);
2009 assert_eq!(list.value_length(0), i32::try_from(levels).unwrap());
2010 assert_eq!(asks.value_length(0), 0);
2011 assert!(!asks.is_null(0));
2012
2013 for i in 0..levels {
2014 assert_eq!(
2015 prices.value(i),
2016 f64::from(u32::try_from(100 + i).unwrap()) + 0.25
2017 );
2018 assert_eq!(
2019 sizes.value(i),
2020 f64::from(u32::try_from(200 + i).unwrap()) + 0.5
2021 );
2022 assert_eq!(counts.value(i), 300 + u32::try_from(i).unwrap());
2023 assert_eq!(ids.value(i), u64::MAX - u64::try_from(i).unwrap());
2024 }
2025 }
2026
2027 #[rstest]
2028 #[case(false, false)]
2029 #[case(false, true)]
2030 #[case(true, false)]
2031 #[case(true, true)]
2032 fn test_depth_display_rejects_legacy_shapes(#[case] lists: bool, #[case] with_ids: bool) {
2033 let depth = stub_depth10();
2034 let metadata = depth.metadata();
2035 let raw = OrderBookDepth::encode_batch(&metadata, &[depth]).unwrap();
2036 let mut columns = Vec::<ArrayRef>::new();
2037 let mut fields = Vec::<Field>::new();
2038
2039 for (side, prefix) in [("bids", "bid"), ("asks", "ask")] {
2040 let list = raw
2041 .column_by_name(side)
2042 .unwrap()
2043 .as_any()
2044 .downcast_ref::<ListArray>()
2045 .unwrap();
2046 let levels = list
2047 .values()
2048 .as_any()
2049 .downcast_ref::<StructArray>()
2050 .unwrap();
2051
2052 for name in ["price", "size", "count", "order_id"] {
2053 if name == "order_id" && !with_ids {
2054 continue;
2055 }
2056 let values = levels.column_by_name(name).unwrap();
2057 if lists {
2058 let item = Arc::new(Field::new("item", values.data_type().clone(), true));
2059 let column =
2060 FixedSizeListArray::try_new(item, 10, values.clone(), None).unwrap();
2061 fields.push(Field::new(
2062 format!("{prefix}_{name}"),
2063 column.data_type().clone(),
2064 false,
2065 ));
2066 columns.push(Arc::new(column));
2067 } else {
2068 for i in 0..10 {
2069 fields.push(Field::new(
2070 format!("{prefix}_{name}_{i}"),
2071 values.data_type().clone(),
2072 true,
2073 ));
2074 columns.push(values.slice(i, 1));
2075 }
2076 }
2077 }
2078 }
2079
2080 for name in ["flags", "sequence", "ts_event", "ts_init"] {
2081 fields.push(raw.schema().field_with_name(name).unwrap().clone());
2082 columns.push(raw.column_by_name(name).unwrap().clone());
2083 }
2084 let legacy = RecordBatch::try_new(
2085 Arc::new(Schema::new_with_metadata(fields, metadata.clone())),
2086 columns,
2087 )
2088 .unwrap();
2089 let error =
2090 catalog_record_batch_to_display(&NautilusDataType::OrderBookDepth, &metadata, &legacy)
2091 .unwrap_err();
2092 assert!(
2093 matches!(error, EncodingError::ParseError("bids", ref message) if message == "expected List<Struct>")
2094 );
2095 }
2096
2097 #[rstest]
2098 fn test_catalog_display_schema_uses_data_type() {
2099 let schema = catalog_display_schema(&NautilusDataType::QuoteTick).unwrap();
2100
2101 assert_eq!(schema.fields()[0].name(), "instrument_id");
2102 assert_eq!(schema.fields()[1].data_type(), &DataType::Float64);
2103 assert_eq!(schema.fields().last().unwrap().name(), "ts_init");
2104 }
2105
2106 #[rstest]
2107 fn test_custom_catalog_display_schema_uses_registered_schema() {
2108 let type_name = "CatalogDisplaySchemaCustom";
2109 let schema = Schema::new(vec![
2110 Field::new("value", DataType::Float64, false),
2111 Field::new(
2112 "ts_init",
2113 DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())),
2114 false,
2115 ),
2116 ]);
2117 ensure_arrow_registered(
2118 type_name,
2119 Arc::new(schema),
2120 Box::new(|_| unreachable!()),
2121 Box::new(|_, _| unreachable!()),
2122 )
2123 .unwrap();
2124
2125 let data_type = NautilusDataType::Custom {
2126 type_name: type_name.to_string(),
2127 };
2128 let raw_schema = catalog_raw_schema(&data_type).unwrap();
2129 let schema = catalog_display_schema(&data_type).unwrap();
2130 let fields = schema
2131 .fields()
2132 .iter()
2133 .map(|field| (field.name().as_str(), field.data_type()))
2134 .collect::<Vec<_>>();
2135
2136 assert_eq!(
2137 fields,
2138 [
2139 ("value", &DataType::Float64),
2140 (
2141 "ts_init",
2142 &DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into()))
2143 ),
2144 ("data_type", &DataType::Utf8),
2145 ("identifier", &DataType::Utf8),
2146 ]
2147 );
2148 assert_eq!(raw_schema.fields().last().unwrap().name(), "identifier");
2149 }
2150
2151 #[rstest]
2152 fn test_custom_catalog_schema_rejects_unregistered_type() {
2153 let data_type = NautilusDataType::Custom {
2154 type_name: "UnregisteredCatalogDisplayCustom".to_string(),
2155 };
2156
2157 assert!(catalog_raw_schema(&data_type).is_err());
2158 assert!(catalog_display_schema(&data_type).is_err());
2159 }
2160
2161 #[rstest]
2162 fn test_custom_display_normalizes_utf8_view_to_utf8() {
2163 let batch = RecordBatch::try_new(
2164 Arc::new(Schema::new(vec![Field::new(
2165 "label",
2166 DataType::Utf8View,
2167 false,
2168 )])),
2169 vec![Arc::new(StringViewArray::from(vec!["value"]))],
2170 )
2171 .unwrap();
2172 let display = catalog_record_batch_to_display(
2173 &NautilusDataType::Custom {
2174 type_name: "ViewCustom".to_string(),
2175 },
2176 batch.schema().metadata(),
2177 &batch,
2178 )
2179 .unwrap();
2180
2181 assert_eq!(display.schema().field(0).data_type(), &DataType::Utf8);
2182 assert_eq!(
2183 display
2184 .column(0)
2185 .as_any()
2186 .downcast_ref::<StringArray>()
2187 .unwrap()
2188 .value(0),
2189 "value"
2190 );
2191 }
2192
2193 #[rstest]
2194 fn test_catalog_record_batch_to_display_converts_funding_rates() {
2195 let instrument_id = InstrumentId::from("BTCUSDT-PERP.BINANCE");
2196 let funding_rate = FundingRateUpdate::new(
2197 instrument_id,
2198 Decimal::from_str("0.000125").unwrap(),
2199 Some(480),
2200 None,
2201 UnixNanos::from(10),
2202 UnixNanos::from(11),
2203 );
2204 let metadata = funding_rate.metadata();
2205 let raw_batch = FundingRateUpdate::encode_batch(&metadata, &[funding_rate]).unwrap();
2206
2207 let display_batch = catalog_record_batch_to_display(
2208 &NautilusDataType::FundingRateUpdate,
2209 &metadata,
2210 &raw_batch,
2211 )
2212 .unwrap();
2213
2214 let mut expected_fields = funding_rate_schema().fields().to_vec();
2215 expected_fields.push(Arc::new(utf8_field(KEY_IDENTIFIER, true)));
2216 assert_eq!(
2217 display_batch.schema(),
2218 Arc::new(Schema::new(expected_fields))
2219 );
2220 let instrument_ids = display_batch
2221 .column(0)
2222 .as_any()
2223 .downcast_ref::<StringArray>()
2224 .unwrap();
2225 let rates = display_batch
2226 .column(1)
2227 .as_any()
2228 .downcast_ref::<Float64Array>()
2229 .unwrap();
2230 let intervals = display_batch
2231 .column(2)
2232 .as_any()
2233 .downcast_ref::<UInt64Array>()
2234 .unwrap();
2235 let next_funding_ns = display_batch
2236 .column(3)
2237 .as_any()
2238 .downcast_ref::<TimestampNanosecondArray>()
2239 .unwrap();
2240 let identifiers = display_batch
2241 .column(6)
2242 .as_any()
2243 .downcast_ref::<StringArray>()
2244 .unwrap();
2245
2246 assert_eq!(instrument_ids.value(0), "BTCUSDT-PERP.BINANCE");
2247 assert_eq!(rates.value(0), 0.000_125);
2248 assert_eq!(intervals.value(0), 480);
2249 assert!(next_funding_ns.is_null(0));
2250 assert_eq!(identifiers.value(0), "BTCUSDT-PERP.BINANCE");
2251 }
2252
2253 #[rstest]
2254 fn test_catalog_record_batch_to_display_rejects_precision_mismatch() {
2255 let metadata = HashMap::from([
2256 (KEY_INSTRUMENT_ID.to_string(), "AUD/USD.SIM".to_string()),
2257 (KEY_PRICE_PRECISION.to_string(), "5".to_string()),
2258 (KEY_SIZE_PRECISION.to_string(), "0".to_string()),
2259 ]);
2260 let schema = Arc::new(Schema::new_with_metadata(
2261 vec![
2262 Field::new("bid_price", DataType::FixedSizeBinary(1), false),
2263 Field::new("ask_price", DataType::FixedSizeBinary(1), false),
2264 Field::new("bid_size", DataType::FixedSizeBinary(1), false),
2265 Field::new("ask_size", DataType::FixedSizeBinary(1), false),
2266 Field::new("ts_event", DataType::UInt64, false),
2267 Field::new("ts_init", DataType::UInt64, false),
2268 ],
2269 metadata.clone(),
2270 ));
2271 let batch = RecordBatch::try_new(
2272 schema,
2273 vec![
2274 Arc::new(
2275 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2276 vec![Some([0_u8].as_slice())].into_iter(),
2277 1,
2278 )
2279 .unwrap(),
2280 ),
2281 Arc::new(
2282 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2283 vec![Some([0_u8].as_slice())].into_iter(),
2284 1,
2285 )
2286 .unwrap(),
2287 ),
2288 Arc::new(
2289 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2290 vec![Some([0_u8].as_slice())].into_iter(),
2291 1,
2292 )
2293 .unwrap(),
2294 ),
2295 Arc::new(
2296 FixedSizeBinaryArray::try_from_sparse_iter_with_size(
2297 vec![Some([0_u8].as_slice())].into_iter(),
2298 1,
2299 )
2300 .unwrap(),
2301 ),
2302 Arc::new(UInt64Array::from(vec![1])),
2303 Arc::new(UInt64Array::from(vec![1])),
2304 ],
2305 )
2306 .unwrap();
2307
2308 let error =
2309 catalog_record_batch_to_display(&NautilusDataType::QuoteTick, &metadata, &batch)
2310 .unwrap_err();
2311
2312 assert!(matches!(
2313 error,
2314 EncodingError::InvalidColumnType(
2315 "bid_price",
2316 _,
2317 DataType::Decimal128(38, 16),
2318 DataType::FixedSizeBinary(1)
2319 )
2320 ));
2321 }
2322
2323 #[cfg(feature = "arrow-display")]
2324 #[rstest]
2325 #[case::bar(NautilusDataType::Bar)]
2326 #[case::trade(NautilusDataType::TradeTick)]
2327 #[case::delta(NautilusDataType::OrderBookDelta)]
2328 #[case::close(NautilusDataType::InstrumentClose)]
2329 fn test_catalog_display_matches_typed_encoder(#[case] data_type: NautilusDataType) {
2330 let (raw, expected) = match data_type {
2331 NautilusDataType::Bar => {
2332 let value = stub_bar();
2333 (
2334 Bar::encode_batch(&value.metadata(), &[value]).unwrap(),
2335 encode_bars(&[value]).unwrap(),
2336 )
2337 }
2338 NautilusDataType::TradeTick => {
2339 let value = stub_trade_ethusdt_buy();
2340 (
2341 TradeTick::encode_batch(&value.metadata(), &[value]).unwrap(),
2342 encode_trades(&[value]).unwrap(),
2343 )
2344 }
2345 NautilusDataType::OrderBookDelta => {
2346 let value = stub_delta();
2347 (
2348 OrderBookDelta::encode_batch(&value.metadata(), &[value]).unwrap(),
2349 encode_deltas(&[value]).unwrap(),
2350 )
2351 }
2352 NautilusDataType::InstrumentClose => {
2353 let value = stub_instrument_close();
2354 (
2355 InstrumentClose::encode_batch(&value.metadata(), &[value]).unwrap(),
2356 encode_instrument_closes(&[value]).unwrap(),
2357 )
2358 }
2359 _ => unreachable!(),
2360 };
2361
2362 let displayed =
2363 catalog_record_batch_to_display(&data_type, raw.schema().metadata(), &raw).unwrap();
2364 let identifier = displayed.column_by_name(KEY_IDENTIFIER).unwrap().clone();
2365 let without_identifier = record_batch_without_identifier_column(displayed).unwrap();
2366
2367 assert_eq!(&identifier, raw.column_by_name(KEY_IDENTIFIER).unwrap());
2368 assert_eq!(without_identifier, expected);
2369 }
2370}