Skip to main content

nautilus_persistence/common/
custom.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this code except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Custom data persistence: shared conversion and orchestration.
17//!
18//! Centralizes the logic for appending the `data_type` column and metadata to Arrow batches
19//! (Parquet/Feather), and custom-data write preparation, path construction, and decode logic
20//! so the catalog delegates here instead of inlining custom-specific branching.
21
22#![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/// Builds a schema that adds the `data_type` column and `type_name` metadata to a base schema.
54/// Used when creating a Feather buffer for custom data (single type per writer).
55#[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
64/// Appends a `data_type` column (JSON string per row) and `type_name` + optional metadata to the
65/// batch schema. Used by both the Parquet catalog and Feather writer for catalog-compatible output.
66///
67/// # Errors
68///
69/// Returns an error if the new `RecordBatch` cannot be created.
70pub 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/// Returns path components for custom data: `["data", "custom", type_name, identifier]`.
104/// Used by the catalog to build full object-store paths via `make_object_store_path`.
105#[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/// Groups custom data by full persistence identity.
123///
124/// The identity includes type name, catalog identifier, and metadata so each Arrow batch has a
125/// consistent schema and `data_type` column.
126#[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
145/// Prepares a batch of custom data for writing: encodes to Arrow, augments with `data_type` column,
146/// and returns type identity and timestamp range so the catalog can build path and perform I/O.
147///
148/// # Errors
149///
150/// Returns an error if encoding or augmentation fails, if the type is not registered, or if the
151/// registered Arrow schema omits `ts_init` or carries timestamps the catalog cannot query.
152pub 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() != &timestamp_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/// Decodes a `RecordBatch` to Data objects based on metadata.
236///
237/// Supports both standard data types and custom data types when `allow_custom_fallback`
238/// is true (e.g. when decoding files under `custom/`). When false, unknown type names
239/// produce an error instead of attempting custom decode.
240///
241/// # Errors
242///
243/// Returns an error if decoding fails or the type is unknown (and custom fallback not allowed).
244#[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
307/// Decodes multiple `RecordBatches` (e.g. from custom data files) into a single `Vec<Data>`.
308/// Optionally replaces `ts_init` column with `ts_event` before decoding each batch.
309///
310/// # Errors
311///
312/// Returns an error if any batch fails to decode.
313pub 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}