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