1use std::{
23 collections::{BTreeMap, HashMap},
24 hash::BuildHasher,
25 sync::Arc,
26};
27
28use datafusion::arrow::{
29 array::{Array, StringArray},
30 datatypes::{DataType as ArrowDataType, Field, Schema},
31 record_batch::RecordBatch,
32};
33use nautilus_core::UnixNanos;
34use nautilus_model::data::{
35 Bar, CustomData, CustomDataTrait, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus,
36 MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
37 TradeTick, close::InstrumentClose, encode_custom_to_arrow, get_arrow_schema,
38};
39use nautilus_serialization::arrow::{
40 DecodeDataFromRecordBatch, custom::CustomDataDecoder, record_batch_with_identifier_column,
41 timestamp_data_type,
42};
43
44use crate::{
45 catalog::types::data_type_from_data_path_prefix, common::paths::urisafe_instrument_id,
46};
47
48#[must_use]
51pub fn schema_with_data_type_column(base_schema: &Schema, type_name: &str) -> Schema {
52 let mut fields: Vec<_> = base_schema.fields().iter().cloned().collect();
53 fields.push(Arc::new(Field::new("data_type", ArrowDataType::Utf8, true)));
54 let mut meta = base_schema.metadata().clone();
55 meta.insert("type_name".to_string(), type_name.to_string());
56 Schema::new_with_metadata(fields, meta)
57}
58
59pub fn augment_batch_with_data_type_column<S: BuildHasher>(
66 batch: &RecordBatch,
67 data_type_json: &str,
68 type_name: &str,
69 dt_meta: Option<&HashMap<String, String, S>>,
70) -> anyhow::Result<RecordBatch> {
71 let num_rows = batch.num_rows();
72 let data_type_array: Arc<dyn Array> =
73 Arc::new(StringArray::from(vec![data_type_json; num_rows]));
74 let schema = batch.schema();
75 let mut fields: Vec<_> = schema.fields().iter().cloned().collect();
76 fields.push(Arc::new(Field::new(
77 "data_type",
78 ArrowDataType::Utf8,
79 false,
80 )));
81 let mut meta = schema.metadata().clone();
82 meta.insert("type_name".to_string(), type_name.to_string());
83
84 if let Some(m) = dt_meta {
85 meta.extend(m.iter().map(|(key, value)| (key.clone(), value.clone())));
86 }
87
88 let new_schema = Arc::new(Schema::new_with_metadata(fields, meta));
89 let mut columns = batch.columns().to_vec();
90 columns.push(data_type_array);
91 RecordBatch::try_new(new_schema, columns)
92 .map_err(|e| anyhow::anyhow!("Failed to merge custom data type metadata: {e}"))
93}
94
95#[must_use]
98pub fn custom_data_path_components(type_name: &str, identifier: Option<&str>) -> Vec<String> {
99 let mut components = vec![
100 "data".to_string(),
101 "custom".to_string(),
102 type_name.to_string(),
103 ];
104
105 if let Some(id) = identifier {
106 let safe = urisafe_instrument_id(id);
107 if !safe.is_empty() {
108 components.push(safe);
109 }
110 }
111
112 components
113}
114
115#[must_use]
120pub fn group_custom_data_by_type<'a>(
121 data: impl IntoIterator<Item = &'a CustomData>,
122) -> Vec<Vec<&'a CustomData>> {
123 let mut grouped: BTreeMap<(String, Option<String>, String), Vec<&'a CustomData>> =
124 BTreeMap::new();
125
126 for custom in data {
127 let key = (
128 custom.data_type.type_name().to_string(),
129 custom.data_type.identifier().map(String::from),
130 custom.data_type.metadata_str(),
131 );
132 grouped.entry(key).or_default().push(custom);
133 }
134
135 grouped.into_values().collect()
136}
137
138pub fn prepare_custom_data_batch(
146 data: &[&CustomData],
147) -> anyhow::Result<(RecordBatch, String, Option<String>, UnixNanos, UnixNanos)> {
148 let Some(first_custom) = data.first() else {
149 anyhow::bail!("prepare_custom_data_batch called with empty data");
150 };
151
152 let type_name = first_custom.data.type_name();
153 let identifier = first_custom.data_type.identifier().map(String::from);
154 let metadata_str = first_custom.data_type.metadata_str();
155 let dt_meta = first_custom.data_type.metadata_string_map();
156 let data_type_json = first_custom
157 .data_type
158 .to_persistence_json()
159 .map_err(|e| anyhow::anyhow!("Failed to serialize data_type for persistence: {e}"))?;
160
161 let mut start_ts = first_custom.data.ts_init();
162 let mut end_ts = start_ts;
163
164 for custom in data {
165 anyhow::ensure!(
166 custom.data.type_name() == type_name
167 && custom.data_type.identifier() == identifier.as_deref()
168 && custom.data_type.metadata_str() == metadata_str,
169 "Cannot prepare one custom data batch from mixed DataType values",
170 );
171
172 let ts_init = custom.data.ts_init();
173 start_ts = start_ts.min(ts_init);
174 end_ts = end_ts.max(ts_init);
175 }
176
177 let items: Vec<Arc<dyn CustomDataTrait>> = data.iter().map(|c| Arc::clone(&c.data)).collect();
178
179 if let Some(schema) = get_arrow_schema(type_name) {
180 validate_custom_catalog_schema(type_name, &schema)?;
181 }
182
183 let batch = encode_custom_to_arrow(type_name, &items)
184 .map_err(|e| anyhow::anyhow!("Failed to encode custom data to Arrow: {e}"))?
185 .ok_or_else(|| {
186 anyhow::anyhow!(
187 "Custom data type \"{type_name}\" is not registered for Arrow encoding; \
188 call register_custom_data_class or ensure_custom_data_registered before writing"
189 )
190 })?;
191
192 let batch =
193 augment_batch_with_data_type_column(&batch, &data_type_json, type_name, dt_meta.as_ref())?;
194 let batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
195
196 Ok((batch, type_name.to_string(), identifier, start_ts, end_ts))
197}
198
199pub(crate) fn validate_custom_catalog_schema(
200 type_name: &str,
201 schema: &Schema,
202) -> anyhow::Result<()> {
203 if schema.field_with_name("ts_init").is_err() {
204 anyhow::bail!(
205 "Custom data type \"{type_name}\" is registered without an Arrow schema containing \
206 ts_init, so written files cannot be queried back; define an `arrow_schema_py()` \
207 class method, or apply the `@customdataclass` decorator"
208 );
209 }
210
211 for name in ["ts_event", "ts_init"] {
212 if let Ok(field) = schema.field_with_name(name)
213 && field.data_type() != ×tamp_data_type()
214 {
215 anyhow::bail!(
216 "Custom data type \"{type_name}\" is registered with {name} as {}, so written \
217 files cannot be queried back; declare it as timestamp(\"ns\", tz=\"UTC\"), or \
218 apply the `@customdataclass` decorator",
219 field.data_type(),
220 );
221 }
222 }
223
224 Ok(())
225}
226
227#[expect(
237 clippy::implicit_hasher,
238 reason = "DecodeDataFromRecordBatch requires the standard HashMap metadata type"
239)]
240pub fn decode_batch_to_data(
241 metadata: &HashMap<String, String>,
242 batch: RecordBatch,
243 allow_custom_fallback: bool,
244) -> anyhow::Result<Vec<Data>> {
245 let type_name = metadata
246 .get("type_name")
247 .cloned()
248 .or_else(|| metadata.get("bar_type").map(|_| "bars".to_string()))
249 .ok_or_else(|| anyhow::anyhow!("Missing type_name in metadata"))?;
250
251 let data_type = match data_type_from_data_path_prefix(&type_name) {
252 Ok(data_type) => data_type,
253 Err(_) if allow_custom_fallback => {
254 return Ok(CustomDataDecoder::decode_data_batch(metadata, batch)?);
255 }
256 Err(e) => return Err(e),
257 };
258
259 macro_rules! decode_builtin_data_batch {
260 (
261 ($data_type:ident, $metadata:ident, $batch:ident, $type_name:ident, $allow_custom:ident);
262 (Instrument, InstrumentAny, Instrument, Instrument, $instrument_prefix:literal),
263 $(($variant:ident, $type:ident, $data:ident, $batch_variant:ident, $prefix:literal)),+ $(,)?
264 ) => {
265 match $data_type {
266 $(
267 NautilusDataType::$variant => {
268 Ok($type::decode_data_batch($metadata, $batch)?)
269 }
270 )+
271 NautilusDataType::Custom { type_name: registered_type_name } if $allow_custom => {
272 let mut metadata = $metadata.clone();
274 metadata.insert("type_name".to_string(), registered_type_name);
275 Ok(CustomDataDecoder::decode_data_batch(&metadata, $batch)?)
276 }
277 NautilusDataType::Custom { .. } => anyhow::bail!(
278 "Unknown data type: {}; custom decode only allowed in custom data context",
279 $type_name,
280 ),
281 NautilusDataType::Instrument => {
282 anyhow::bail!("Instrument batches require instrument-specific decoding")
283 }
284 #[cfg(feature = "defi")]
285 NautilusDataType::Defi => {
286 anyhow::bail!("DeFi batches require DeFi-specific decoding")
287 }
288 #[cfg(not(feature = "defi"))]
289 #[allow(unreachable_patterns, reason = "DeFi variants can exist without this crate's defi feature")]
290 _ => anyhow::bail!("DeFi batches require DeFi-specific decoding"),
291 }
292 };
293 }
294
295 nautilus_model::for_each_data_type!(
296 decode_builtin_data_batch,
297 data_type,
298 metadata,
299 batch,
300 type_name,
301 allow_custom_fallback
302 )
303}
304
305pub fn decode_custom_batches_to_data(
312 batches: Vec<RecordBatch>,
313 use_ts_event_for_ts_init: bool,
314) -> anyhow::Result<Vec<Data>> {
315 let Some(first_batch) = batches.first() else {
316 return Ok(Vec::new());
317 };
318
319 let schema = first_batch.schema();
320
321 let ts_columns = if use_ts_event_for_ts_init {
322 schema
323 .index_of("ts_event")
324 .ok()
325 .zip(schema.index_of("ts_init").ok())
326 } else {
327 None
328 };
329
330 let mut file_data = Vec::new();
331
332 for mut batch in batches {
333 if let Some((ts_event_idx, ts_init_idx)) = ts_columns {
334 let mut new_columns = batch.columns().to_vec();
335 new_columns[ts_init_idx] = new_columns[ts_event_idx].clone();
336 batch = RecordBatch::try_new(schema.clone(), new_columns)
337 .map_err(|e| anyhow::anyhow!("Failed to create new batch: {e}"))?;
338 }
339
340 let metadata = batch.schema().metadata().clone();
341 let decoded = decode_batch_to_data(&metadata, batch, true)?;
342 file_data.extend(decoded);
343 }
344
345 Ok(file_data)
346}
347
348#[cfg(test)]
349mod tests {
350 use std::{collections::HashMap, sync::Arc};
351
352 use datafusion::arrow::{
353 datatypes::{DataType as ArrowDataType, Field, Schema},
354 record_batch::RecordBatch,
355 };
356 use nautilus_core::{Params, UnixNanos};
357 use nautilus_model::{
358 data::{CustomData, Data, DataType, QuoteTick},
359 identifiers::InstrumentId,
360 types::{Price, Quantity},
361 };
362 use nautilus_serialization::{
363 arrow::{EncodeToRecordBatch, timestamp_data_type},
364 ensure_custom_data_registered,
365 };
366 use rstest::rstest;
367
368 use super::{
369 custom_data_path_components, decode_batch_to_data, decode_custom_batches_to_data,
370 group_custom_data_by_type, prepare_custom_data_batch, validate_custom_catalog_schema,
371 };
372 use crate::test_data::RustTestCustomData;
373
374 #[rstest]
375 fn test_validate_custom_catalog_schema_accepts_catalog_timestamps() {
376 let schema = schema_with_timestamps(timestamp_data_type(), timestamp_data_type());
377
378 assert!(validate_custom_catalog_schema("SensorReading", &schema).is_ok());
379 }
380
381 #[rstest]
382 fn test_validate_custom_catalog_schema_rejects_empty_schema() {
383 let error = validate_custom_catalog_schema("SensorReading", &Schema::empty()).unwrap_err();
384
385 assert_eq!(
386 error.to_string(),
387 "Custom data type \"SensorReading\" is registered without an Arrow schema containing \
388 ts_init, so written files cannot be queried back; define an `arrow_schema_py()` \
389 class method, or apply the `@customdataclass` decorator"
390 );
391 }
392
393 #[rstest]
394 #[case("ts_event", ArrowDataType::UInt64, timestamp_data_type())]
395 #[case("ts_init", timestamp_data_type(), ArrowDataType::UInt64)]
396 fn test_validate_custom_catalog_schema_rejects_integer_timestamps(
397 #[case] expected_name: &str,
398 #[case] ts_event: ArrowDataType,
399 #[case] ts_init: ArrowDataType,
400 ) {
401 let schema = schema_with_timestamps(ts_event, ts_init);
402
403 let error = validate_custom_catalog_schema("SensorReading", &schema).unwrap_err();
404
405 assert_eq!(
406 error.to_string(),
407 format!(
408 "Custom data type \"SensorReading\" is registered with {expected_name} as UInt64, \
409 so written files cannot be queried back; declare it as \
410 timestamp(\"ns\", tz=\"UTC\"), or apply the `@customdataclass` decorator"
411 )
412 );
413 }
414
415 fn schema_with_timestamps(ts_event: ArrowDataType, ts_init: ArrowDataType) -> Schema {
416 Schema::new(vec![
417 Field::new("value", ArrowDataType::Float64, false),
418 Field::new("ts_event", ts_event, false),
419 Field::new("ts_init", ts_init, false),
420 ])
421 }
422
423 fn test_custom(
424 identifier: &str,
425 ts_event: u64,
426 ts_init: u64,
427 metadata: Option<Params>,
428 ) -> CustomData {
429 CustomData::new(
430 Arc::new(RustTestCustomData {
431 instrument_id: InstrumentId::from("RUST.TEST"),
432 value: 1.5,
433 flag: true,
434 ts_event: UnixNanos::from(ts_event),
435 ts_init: UnixNanos::from(ts_init),
436 }),
437 DataType::new("RustTestCustomData", metadata, Some(identifier.to_string())),
438 )
439 }
440
441 fn custom_rows(data: &[Data]) -> Vec<(String, Option<String>, u64, u64)> {
442 data.iter()
443 .map(|data| match data {
444 Data::Custom(custom) => (
445 custom.data_type.type_name().to_string(),
446 custom.data_type.identifier().map(str::to_string),
447 custom.data.ts_event().as_u64(),
448 custom.data.ts_init().as_u64(),
449 ),
450 other => panic!("Expected custom data, received {other:?}"),
451 })
452 .collect()
453 }
454
455 #[rstest]
456 #[case::without_identifier(None, &["data", "custom", "SensorReading"])]
457 #[case::sanitized_identifier(
458 Some("BTC/USD.BINANCE"),
459 &["data", "custom", "SensorReading", "BTCUSD.BINANCE"]
460 )]
461 #[case::identifier_empty_after_sanitizing(Some("/"), &["data", "custom", "SensorReading"])]
462 fn custom_data_path_components_sanitize_identifier(
463 #[case] identifier: Option<&str>,
464 #[case] expected: &[&str],
465 ) {
466 assert_eq!(
467 custom_data_path_components("SensorReading", identifier),
468 expected
469 );
470 }
471
472 #[rstest]
473 fn group_custom_data_by_type_groups_by_identifier_and_metadata() {
474 let mut metadata = Params::new();
475 metadata.insert("source".to_string(), "replay".into());
476 let data = [
477 test_custom("A", 1, 1, None),
478 test_custom("B", 2, 2, None),
479 test_custom("A", 3, 3, None),
480 test_custom("A", 4, 4, Some(metadata)),
481 ];
482
483 let groups = group_custom_data_by_type(data.iter())
484 .into_iter()
485 .map(|group| {
486 group
487 .iter()
488 .map(|custom| custom.data.ts_init().as_u64())
489 .collect::<Vec<_>>()
490 })
491 .collect::<Vec<_>>();
492
493 assert_eq!(groups, vec![vec![1, 3], vec![4], vec![2]]);
494 }
495
496 #[rstest]
497 fn prepare_custom_data_batch_rejects_empty_input() {
498 let error = prepare_custom_data_batch(&[]).unwrap_err();
499
500 assert_eq!(
501 error.to_string(),
502 "prepare_custom_data_batch called with empty data"
503 );
504 }
505
506 #[rstest]
507 #[case::identifier(test_custom("B", 2, 2, None))]
508 #[case::metadata({
509 let mut metadata = Params::new();
510 metadata.insert("source".to_string(), "replay".into());
511 test_custom("A", 2, 2, Some(metadata))
512 })]
513 fn prepare_custom_data_batch_rejects_mixed_data_types(#[case] other: CustomData) {
514 let first = test_custom("A", 1, 1, None);
515
516 let error = prepare_custom_data_batch(&[&first, &other]).unwrap_err();
517
518 assert_eq!(
519 error.to_string(),
520 "Cannot prepare one custom data batch from mixed DataType values"
521 );
522 }
523
524 #[rstest]
525 fn prepare_custom_data_batch_reports_ts_init_range_of_unordered_rows() {
526 ensure_custom_data_registered::<RustTestCustomData>();
527 let data = [
528 test_custom("A", 21, 21, None),
529 test_custom("A", 11, 11, None),
530 test_custom("A", 31, 31, None),
531 ];
532 let refs = data.iter().collect::<Vec<_>>();
533
534 let (_, _, _, start, end) = prepare_custom_data_batch(&refs).unwrap();
535
536 assert_eq!((start, end), (UnixNanos::from(11), UnixNanos::from(31)));
537 }
538
539 #[rstest]
540 fn decode_custom_batches_to_data_returns_empty_for_no_batches() {
541 let decoded = decode_custom_batches_to_data(Vec::new(), true).unwrap();
542
543 assert_eq!(decoded, Vec::<Data>::new());
544 }
545
546 #[rstest]
547 #[case::keeps_ts_init(false, [11, 21])]
548 #[case::uses_ts_event(true, [10, 20])]
549 fn prepared_custom_batch_decodes_back_to_custom_data(
550 #[case] use_ts_event_for_ts_init: bool,
551 #[case] expected_ts_init: [u64; 2],
552 ) {
553 ensure_custom_data_registered::<RustTestCustomData>();
554 let data = [
555 test_custom("A", 10, 11, None),
556 test_custom("A", 20, 21, None),
557 ];
558 let refs = data.iter().collect::<Vec<_>>();
559
560 let (batch, type_name, identifier, start, end) = prepare_custom_data_batch(&refs).unwrap();
561 let decoded = decode_custom_batches_to_data(vec![batch], use_ts_event_for_ts_init).unwrap();
562
563 assert_eq!(type_name, "RustTestCustomData");
564 assert_eq!(identifier.as_deref(), Some("A"));
565 assert_eq!((start, end), (UnixNanos::from(11), UnixNanos::from(21)));
566 assert_eq!(
567 custom_rows(&decoded),
568 vec![
569 (
570 "RustTestCustomData".to_string(),
571 Some("A".to_string()),
572 10,
573 expected_ts_init[0],
574 ),
575 (
576 "RustTestCustomData".to_string(),
577 Some("A".to_string()),
578 20,
579 expected_ts_init[1],
580 ),
581 ],
582 );
583 }
584
585 fn prepared_custom_metadata(type_name: Option<&str>) -> (HashMap<String, String>, RecordBatch) {
586 ensure_custom_data_registered::<RustTestCustomData>();
587 let custom = test_custom("A", 10, 10, None);
588 let batch = prepare_custom_data_batch(&[&custom]).unwrap().0;
589 let mut metadata = batch.schema().metadata().clone();
590 metadata.remove("type_name");
591
592 if let Some(type_name) = type_name {
593 metadata.insert("type_name".to_string(), type_name.to_string());
594 }
595
596 (metadata, batch)
597 }
598
599 #[rstest]
600 #[case::missing_type_name(None, "Missing type_name in metadata")]
601 #[case::bare_custom_type_name(
602 Some("RustTestCustomData"),
603 "Invalid `NautilusDataType`: 'RustTestCustomData'"
604 )]
605 #[case::custom_prefix(
606 Some("custom/RustTestCustomData"),
607 "Unknown data type: custom/RustTestCustomData; custom decode only allowed in custom data context"
608 )]
609 fn decode_batch_to_data_rejects_custom_types_outside_custom_context(
610 #[case] type_name: Option<&str>,
611 #[case] expected: &str,
612 ) {
613 let (metadata, batch) = prepared_custom_metadata(type_name);
614
615 let error = decode_batch_to_data(&metadata, batch, false).unwrap_err();
616
617 assert_eq!(error.to_string(), expected);
618 }
619
620 #[rstest]
621 #[case::bare_custom_type_name("RustTestCustomData")]
622 #[case::custom_path_prefix("custom/RustTestCustomData")]
623 #[case::custom_type_prefix("Custom:RustTestCustomData")]
624 fn decode_batch_to_data_decodes_custom_types_in_custom_context(#[case] type_name: &str) {
625 let (metadata, batch) = prepared_custom_metadata(Some(type_name));
626
627 let decoded = decode_batch_to_data(&metadata, batch, true).unwrap();
628
629 assert_eq!(
630 custom_rows(&decoded),
631 vec![(
632 "RustTestCustomData".to_string(),
633 Some("A".to_string()),
634 10,
635 10
636 )],
637 );
638 }
639
640 #[rstest]
641 fn decode_batch_to_data_decodes_built_in_type_names() {
642 let quote = QuoteTick::new(
643 InstrumentId::from("AUD/USD.SIM"),
644 Price::from("1.00001"),
645 Price::from("1.00002"),
646 Quantity::from("100000"),
647 Quantity::from("200000"),
648 UnixNanos::from(1),
649 UnixNanos::from(2),
650 );
651 let mut metadata = quote.metadata();
652 let batch = QuoteTick::encode_batch(&metadata, &[quote]).unwrap();
653 metadata.insert("type_name".to_string(), "quotes".to_string());
654
655 let decoded = decode_batch_to_data(&metadata, batch, false).unwrap();
656
657 assert_eq!(decoded, vec![Data::Quote(quote)]);
658 }
659}