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
22use 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/// Builds a schema that adds the `data_type` column and `type_name` metadata to a base schema.
49/// Used when creating a Feather buffer for custom data (single type per writer).
50#[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
59/// Appends a `data_type` column (JSON string per row) and `type_name` + optional metadata to the
60/// batch schema. Used by both the Parquet catalog and Feather writer for catalog-compatible output.
61///
62/// # Errors
63///
64/// Returns an error if the new `RecordBatch` cannot be created.
65pub 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/// Returns path components for custom data: `["data", "custom", type_name, identifier]`.
96/// Used by the catalog to build full object-store paths via `make_object_store_path`.
97#[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/// Groups custom data by full persistence identity.
116///
117/// The identity includes type name, catalog identifier, and metadata so each Arrow batch has a
118/// consistent schema and `data_type` column.
119#[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
138/// Prepares a batch of custom data for writing: encodes to Arrow, augments with `data_type` column,
139/// and returns type identity and timestamp range so the catalog can build path and perform I/O.
140///
141/// # Errors
142///
143/// Returns an error if encoding or augmentation fails, if the type is not registered, or if the
144/// registered Arrow schema omits `ts_init` or carries timestamps the catalog cannot query.
145pub 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() != &timestamp_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/// Decodes a `RecordBatch` to Data objects based on metadata.
228///
229/// Supports both standard data types and custom data types when `allow_custom_fallback`
230/// is true (e.g. when decoding files under `custom/`). When false, unknown type names
231/// produce an error instead of attempting custom decode.
232///
233/// # Errors
234///
235/// Returns an error if decoding fails or the type is unknown (and custom fallback not allowed).
236#[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                    // The decoder registry is keyed by the bare type name
273                    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
305/// Decodes multiple `RecordBatches` (e.g. from custom data files) into a single `Vec<Data>`.
306/// Optionally replaces `ts_init` column with `ts_event` before decoding each batch.
307///
308/// # Errors
309///
310/// Returns an error if any batch fails to decode.
311pub 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}