1#![expect(
23 clippy::missing_panics_doc,
24 reason = "custom Arrow conversion validates non-empty inputs before internal unwraps"
25)]
26
27use std::{
28 collections::{BTreeMap, HashMap},
29 hash::BuildHasher,
30 sync::Arc,
31};
32
33use datafusion::arrow::{
34 array::{Array, StringArray},
35 datatypes::{DataType as ArrowDataType, Field, Schema},
36 record_batch::RecordBatch,
37};
38use nautilus_core::UnixNanos;
39use nautilus_model::data::{
40 Bar, CustomData, CustomDataTrait, Data, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus,
41 MarkPriceUpdate, NautilusDataType, OptionGreeks, OrderBookDelta, OrderBookDepth, QuoteTick,
42 TradeTick, close::InstrumentClose, encode_custom_to_arrow, get_arrow_schema,
43};
44use nautilus_serialization::arrow::{
45 DecodeDataFromRecordBatch, custom::CustomDataDecoder, record_batch_with_identifier_column,
46 timestamp_data_type,
47};
48
49use crate::{
50 catalog::types::data_type_from_data_path_prefix, common::paths::urisafe_instrument_id,
51};
52
53#[must_use]
56pub fn schema_with_data_type_column(base_schema: &Schema, type_name: &str) -> Schema {
57 let mut fields: Vec<_> = base_schema.fields().iter().cloned().collect();
58 fields.push(Arc::new(Field::new("data_type", ArrowDataType::Utf8, true)));
59 let mut meta = base_schema.metadata().clone();
60 meta.insert("type_name".to_string(), type_name.to_string());
61 Schema::new_with_metadata(fields, meta)
62}
63
64pub fn augment_batch_with_data_type_column<S: BuildHasher>(
71 batch: &RecordBatch,
72 data_type_json: &str,
73 type_name: &str,
74 dt_meta: Option<&HashMap<String, String, S>>,
75) -> anyhow::Result<RecordBatch> {
76 let num_rows = batch.num_rows();
77 let data_type_array: Arc<dyn Array> = Arc::new(StringArray::from(
78 (0..num_rows)
79 .map(|_| Some(data_type_json))
80 .collect::<Vec<_>>(),
81 ));
82 let schema = batch.schema();
83 let mut fields: Vec<_> = schema.fields().iter().cloned().collect();
84 fields.push(Arc::new(Field::new(
85 "data_type",
86 ArrowDataType::Utf8,
87 false,
88 )));
89 let mut meta = schema.metadata().clone();
90 meta.insert("type_name".to_string(), type_name.to_string());
91
92 if let Some(m) = dt_meta {
93 meta.extend(m.iter().map(|(key, value)| (key.clone(), value.clone())));
94 }
95 let new_schema = Arc::new(Schema::new_with_metadata(fields, meta));
96 let mut columns = batch.columns().to_vec();
97 columns.push(data_type_array);
98 let new_batch = RecordBatch::try_new(new_schema, columns)
99 .map_err(|e| anyhow::anyhow!("Failed to merge custom data type metadata: {e}"))?;
100 Ok(new_batch)
101}
102
103#[must_use]
106pub fn custom_data_path_components(type_name: &str, identifier: Option<&str>) -> Vec<String> {
107 let mut components = vec![
108 "data".to_string(),
109 "custom".to_string(),
110 type_name.to_string(),
111 ];
112
113 if let Some(id) = identifier {
114 let safe = urisafe_instrument_id(id);
115 if !safe.is_empty() {
116 components.push(safe);
117 }
118 }
119 components
120}
121
122#[must_use]
127pub fn group_custom_data_by_type<'a>(
128 data: impl IntoIterator<Item = &'a CustomData>,
129) -> Vec<Vec<&'a CustomData>> {
130 let mut grouped: BTreeMap<(String, Option<String>, String), Vec<&'a CustomData>> =
131 BTreeMap::new();
132
133 for custom in data {
134 let key = (
135 custom.data_type.type_name().to_string(),
136 custom.data_type.identifier().map(String::from),
137 custom.data_type.metadata_str(),
138 );
139 grouped.entry(key).or_default().push(custom);
140 }
141
142 grouped.into_values().collect()
143}
144
145pub fn prepare_custom_data_batch(
153 data: &[&CustomData],
154) -> anyhow::Result<(RecordBatch, String, Option<String>, UnixNanos, UnixNanos)> {
155 if data.is_empty() {
156 anyhow::bail!("prepare_custom_data_batch called with empty data");
157 }
158
159 let first_custom = data.first().unwrap();
160 let type_name = first_custom.data.type_name();
161 let identifier = first_custom.data_type.identifier().map(String::from);
162 let metadata_str = first_custom.data_type.metadata_str();
163 let dt_meta = first_custom.data_type.metadata_string_map();
164 let data_type_json = first_custom
165 .data_type
166 .to_persistence_json()
167 .map_err(|e| anyhow::anyhow!("Failed to serialize data_type for persistence: {e}"))?;
168
169 for custom in data {
170 anyhow::ensure!(
171 custom.data.type_name() == type_name
172 && custom.data_type.identifier() == identifier.as_deref()
173 && custom.data_type.metadata_str() == metadata_str,
174 "Cannot prepare one custom data batch from mixed DataType values",
175 );
176 }
177
178 let items: Vec<Arc<dyn CustomDataTrait>> = data.iter().map(|c| Arc::clone(&c.data)).collect();
179 let mut start_ts = items[0].ts_init();
180 let mut end_ts = start_ts;
181
182 for item in &items[1..] {
183 let ts_init = item.ts_init();
184 start_ts = start_ts.min(ts_init);
185 end_ts = end_ts.max(ts_init);
186 }
187
188 if let Some(schema) = get_arrow_schema(type_name) {
189 validate_custom_catalog_schema(type_name, &schema)?;
190 }
191
192 let batch = encode_custom_to_arrow(type_name, &items)
193 .map_err(|e| anyhow::anyhow!("Failed to encode custom data to Arrow: {e}"))?
194 .ok_or_else(|| {
195 anyhow::anyhow!(
196 "Custom data type \"{type_name}\" is not registered for Arrow encoding; \
197 call register_custom_data_class or ensure_custom_data_registered before writing"
198 )
199 })?;
200 let batch =
201 augment_batch_with_data_type_column(&batch, &data_type_json, type_name, dt_meta.as_ref())?;
202 let batch = record_batch_with_identifier_column(batch, identifier.as_deref())?;
203
204 Ok((batch, type_name.to_string(), identifier, start_ts, end_ts))
205}
206
207pub(crate) fn validate_custom_catalog_schema(
208 type_name: &str,
209 schema: &Schema,
210) -> anyhow::Result<()> {
211 if schema.field_with_name("ts_init").is_err() {
212 anyhow::bail!(
213 "Custom data type \"{type_name}\" is registered without an Arrow schema containing \
214 ts_init, so written files cannot be queried back; define an `arrow_schema_py()` \
215 class method, or apply the `@customdataclass` decorator"
216 );
217 }
218
219 for name in ["ts_event", "ts_init"] {
220 if let Ok(field) = schema.field_with_name(name)
221 && field.data_type() != ×tamp_data_type()
222 {
223 anyhow::bail!(
224 "Custom data type \"{type_name}\" is registered with {name} as {}, so written \
225 files cannot be queried back; declare it as timestamp(\"ns\", tz=\"UTC\"), or \
226 apply the `@customdataclass` decorator",
227 field.data_type(),
228 );
229 }
230 }
231
232 Ok(())
233}
234
235#[expect(
245 clippy::implicit_hasher,
246 reason = "DecodeDataFromRecordBatch requires the standard HashMap metadata type"
247)]
248pub fn decode_batch_to_data(
249 metadata: &HashMap<String, String>,
250 batch: RecordBatch,
251 allow_custom_fallback: bool,
252) -> anyhow::Result<Vec<Data>> {
253 let type_name = metadata
254 .get("type_name")
255 .cloned()
256 .or_else(|| metadata.get("bar_type").map(|_| "bars".to_string()))
257 .ok_or_else(|| anyhow::anyhow!("Missing type_name in metadata"))?;
258
259 let data_type = match data_type_from_data_path_prefix(&type_name) {
260 Ok(data_type) => data_type,
261 Err(_) if allow_custom_fallback => {
262 return Ok(CustomDataDecoder::decode_data_batch(metadata, batch)?);
263 }
264 Err(e) => return Err(e),
265 };
266
267 macro_rules! decode_builtin_data_batch {
268 (
269 ($data_type:ident, $metadata:ident, $batch:ident, $type_name:ident, $allow_custom:ident);
270 (Instrument, InstrumentAny, Instrument, Instrument, $instrument_prefix:literal),
271 $(($variant:ident, $type:ident, $data:ident, $batch_variant:ident, $prefix:literal)),+ $(,)?
272 ) => {
273 match $data_type {
274 $(
275 NautilusDataType::$variant => {
276 Ok($type::decode_data_batch($metadata, $batch)?)
277 }
278 )+
279 NautilusDataType::Custom { .. } if $allow_custom => {
280 Ok(CustomDataDecoder::decode_data_batch($metadata, $batch)?)
281 }
282 NautilusDataType::Custom { .. } => anyhow::bail!(
283 "Unknown data type: {}; custom decode only allowed in custom data context",
284 $type_name,
285 ),
286 NautilusDataType::Instrument => {
287 anyhow::bail!("Instrument batches require instrument-specific decoding")
288 }
289 #[cfg(feature = "defi")]
290 NautilusDataType::Defi => {
291 anyhow::bail!("DeFi batches require DeFi-specific decoding")
292 }
293 }
294 };
295 }
296
297 nautilus_model::for_each_data_type!(
298 decode_builtin_data_batch,
299 data_type,
300 metadata,
301 batch,
302 type_name,
303 allow_custom_fallback
304 )
305}
306
307pub fn decode_custom_batches_to_data(
314 batches: Vec<RecordBatch>,
315 use_ts_event_for_ts_init: bool,
316) -> anyhow::Result<Vec<Data>> {
317 if batches.is_empty() {
318 return Ok(Vec::new());
319 }
320
321 let mut file_data = Vec::new();
322 let schema = batches
323 .first()
324 .map(arrow::array::RecordBatch::schema)
325 .expect("empty batches returned above");
326
327 for mut batch in batches {
328 if use_ts_event_for_ts_init {
329 let column_names: Vec<String> =
330 schema.fields().iter().map(|f| f.name().clone()).collect();
331
332 if let (Some(ts_event_idx), Some(ts_init_idx)) = (
333 column_names.iter().position(|n| n == "ts_event"),
334 column_names.iter().position(|n| n == "ts_init"),
335 ) {
336 let mut new_columns = batch.columns().to_vec();
337 new_columns[ts_init_idx] = new_columns[ts_event_idx].clone();
338 batch = RecordBatch::try_new(schema.clone(), new_columns)
339 .map_err(|e| anyhow::anyhow!("Failed to create new batch: {e}"))?;
340 }
341 }
342 let metadata = batch.schema().metadata().clone();
343 let decoded = decode_batch_to_data(&metadata, batch, true)?;
344 file_data.extend(decoded);
345 }
346 Ok(file_data)
347}
348
349#[cfg(test)]
350mod tests {
351 use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
352 use nautilus_serialization::arrow::timestamp_data_type;
353 use rstest::rstest;
354
355 use super::validate_custom_catalog_schema;
356
357 #[rstest]
358 fn test_validate_custom_catalog_schema_accepts_catalog_timestamps() {
359 let schema = schema_with_timestamps(timestamp_data_type(), timestamp_data_type());
360
361 assert!(validate_custom_catalog_schema("SensorReading", &schema).is_ok());
362 }
363
364 #[rstest]
365 fn test_validate_custom_catalog_schema_rejects_empty_schema() {
366 let error = validate_custom_catalog_schema("SensorReading", &Schema::empty()).unwrap_err();
367
368 assert_eq!(
369 error.to_string(),
370 "Custom data type \"SensorReading\" is registered without an Arrow schema containing \
371 ts_init, so written files cannot be queried back; define an `arrow_schema_py()` \
372 class method, or apply the `@customdataclass` decorator"
373 );
374 }
375
376 #[rstest]
377 #[case("ts_event", ArrowDataType::UInt64, timestamp_data_type())]
378 #[case("ts_init", timestamp_data_type(), ArrowDataType::UInt64)]
379 fn test_validate_custom_catalog_schema_rejects_integer_timestamps(
380 #[case] expected_name: &str,
381 #[case] ts_event: ArrowDataType,
382 #[case] ts_init: ArrowDataType,
383 ) {
384 let schema = schema_with_timestamps(ts_event, ts_init);
385
386 let error = validate_custom_catalog_schema("SensorReading", &schema).unwrap_err();
387
388 assert_eq!(
389 error.to_string(),
390 format!(
391 "Custom data type \"SensorReading\" is registered with {expected_name} as UInt64, \
392 so written files cannot be queried back; declare it as \
393 timestamp(\"ns\", tz=\"UTC\"), or apply the `@customdataclass` decorator"
394 )
395 );
396 }
397
398 fn schema_with_timestamps(ts_event: ArrowDataType, ts_init: ArrowDataType) -> Schema {
399 Schema::new(vec![
400 Field::new("value", ArrowDataType::Float64, false),
401 Field::new("ts_event", ts_event, false),
402 Field::new("ts_init", ts_init, false),
403 ])
404 }
405}