Skip to main content

nautilus_serialization/python/
arrow.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 file 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
16use std::{io::Cursor, sync::Arc};
17
18use arrow::{
19    datatypes::Schema,
20    ffi_stream::FFI_ArrowArrayStream,
21    ipc::{reader::StreamReader, writer::StreamWriter},
22    record_batch::{RecordBatch, RecordBatchIterator},
23};
24use nautilus_core::python::{to_pyruntime_err, to_pytype_err, to_pyvalue_err};
25use nautilus_model::{
26    data::{
27        Bar, FundingRateUpdate, IndexPriceUpdate, InstrumentStatus, MarkPriceUpdate, OptionGreeks,
28        OrderBookDelta, OrderBookDepth, QuoteTick, TradeTick, close::InstrumentClose,
29    },
30    python::data::{
31        pyobjects_to_bars, pyobjects_to_book_deltas, pyobjects_to_index_prices,
32        pyobjects_to_instrument_closes, pyobjects_to_instrument_statuses, pyobjects_to_mark_prices,
33        pyobjects_to_option_greeks, pyobjects_to_quotes, pyobjects_to_trades,
34    },
35};
36use pyo3::{
37    conversion::IntoPyObjectExt,
38    prelude::*,
39    types::{PyBytes, PyCapsule, PyType},
40};
41
42use crate::arrow::{
43    ArrowSchemaProvider, DecodeFromRecordBatch, DecodeTypedFromRecordBatch,
44    bars_to_arrow_record_batch_bytes, book_deltas_to_arrow_record_batch_bytes,
45    book_depths_to_arrow_record_batch_bytes, index_prices_to_arrow_record_batch_bytes,
46    instrument_closes_to_arrow_record_batch_bytes, instrument_status_to_arrow_record_batch_bytes,
47    mark_prices_to_arrow_record_batch_bytes, option_greeks_to_arrow_record_batch_bytes,
48    quotes_to_arrow_record_batch_bytes, trades_to_arrow_record_batch_bytes,
49};
50
51/// Transforms the given record `batch` into Python `bytes`.
52///
53/// # Errors
54///
55/// Returns a `PyErr` if writing the Arrow IPC stream fails.
56pub fn arrow_record_batch_to_pybytes(py: Python, batch: &RecordBatch) -> PyResult<Py<PyBytes>> {
57    arrow_record_batches_to_pybytes(py, &batch.schema(), std::slice::from_ref(batch))
58}
59
60/// Transforms the given record `batches` into Python `bytes` as a single Arrow IPC stream.
61///
62/// # Errors
63///
64/// Returns a `PyErr` if writing the Arrow IPC stream fails.
65pub fn arrow_record_batches_to_pybytes(
66    py: Python,
67    schema: &Schema,
68    batches: &[RecordBatch],
69) -> PyResult<Py<PyBytes>> {
70    let mut cursor = Cursor::new(Vec::new());
71    {
72        let mut writer = StreamWriter::try_new(&mut cursor, schema).map_err(to_pyruntime_err)?;
73
74        for batch in batches {
75            writer.write(batch).map_err(to_pyruntime_err)?;
76        }
77
78        writer.finish().map_err(to_pyruntime_err)?;
79    }
80
81    let buffer = cursor.into_inner();
82    let pybytes = PyBytes::new(py, &buffer);
83
84    Ok(pybytes.into())
85}
86
87/// Exports the given record `batches` as an Arrow C stream PyCapsule.
88///
89/// # Errors
90///
91/// Returns a `PyErr` if creating the PyCapsule fails.
92pub fn arrow_record_batches_to_pyarrow_stream(
93    py: Python<'_>,
94    schema: &Schema,
95    batches: Vec<RecordBatch>,
96) -> PyResult<Py<PyAny>> {
97    let reader = RecordBatchIterator::new(batches.into_iter().map(Ok), Arc::new(schema.clone()));
98    let stream = FFI_ArrowArrayStream::new(Box::new(reader));
99
100    // The Arrow PyCapsule protocol requires this exact name for ArrowArrayStream values.
101    let capsule = PyCapsule::new_with_value(py, stream, c"arrow_array_stream")?;
102    Ok(capsule.into_any().unbind())
103}
104
105/// Returns a mapping from field names to Arrow data types for the given Rust data class.
106///
107/// # Errors
108///
109/// Returns a `PyErr` if the class name is not recognized or schema extraction fails.
110#[pyfunction]
111#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
112pub fn get_arrow_schema_map(py: Python<'_>, cls: &Bound<'_, PyType>) -> PyResult<Py<PyAny>> {
113    let cls_str: String = cls.getattr("__name__")?.extract()?;
114    let result_map = match cls_str.as_str() {
115        stringify!(OrderBookDelta) => OrderBookDelta::get_schema_map(),
116        stringify!(OrderBookDepth) => OrderBookDepth::get_schema_map(),
117        stringify!(QuoteTick) => QuoteTick::get_schema_map(),
118        stringify!(TradeTick) => TradeTick::get_schema_map(),
119        stringify!(Bar) => Bar::get_schema_map(),
120        stringify!(MarkPriceUpdate) => MarkPriceUpdate::get_schema_map(),
121        stringify!(IndexPriceUpdate) => IndexPriceUpdate::get_schema_map(),
122        stringify!(FundingRateUpdate) => FundingRateUpdate::get_schema_map(),
123        stringify!(InstrumentStatus) => InstrumentStatus::get_schema_map(),
124        stringify!(OptionGreeks) => OptionGreeks::get_schema_map(),
125        stringify!(InstrumentClose) => InstrumentClose::get_schema_map(),
126        _ => {
127            return Err(to_pytype_err(format!(
128                "Arrow schema for `{cls_str}` is not currently implemented in Rust."
129            )));
130        }
131    };
132
133    result_map.into_py_any(py)
134}
135
136/// Returns an Arrow IPC stream containing the Rust schema for the given data class.
137///
138/// # Errors
139///
140/// Returns a `PyErr` if the class name is not recognized or schema serialization fails.
141#[pyfunction]
142#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
143pub fn get_arrow_schema_bytes(py: Python<'_>, cls: &Bound<'_, PyType>) -> PyResult<Py<PyBytes>> {
144    let cls_str: String = cls.getattr("__name__")?.extract()?;
145    let schema = match cls_str.as_str() {
146        stringify!(OrderBookDelta) => OrderBookDelta::get_schema(None),
147        stringify!(OrderBookDepth) => OrderBookDepth::get_schema(None),
148        stringify!(QuoteTick) => QuoteTick::get_schema(None),
149        stringify!(TradeTick) => TradeTick::get_schema(None),
150        stringify!(Bar) => Bar::get_schema(None),
151        stringify!(MarkPriceUpdate) => MarkPriceUpdate::get_schema(None),
152        stringify!(IndexPriceUpdate) => IndexPriceUpdate::get_schema(None),
153        stringify!(FundingRateUpdate) => FundingRateUpdate::get_schema(None),
154        stringify!(InstrumentStatus) => InstrumentStatus::get_schema(None),
155        stringify!(OptionGreeks) => OptionGreeks::get_schema(None),
156        stringify!(InstrumentClose) => InstrumentClose::get_schema(None),
157        _ => {
158            return Err(to_pytype_err(format!(
159                "Arrow schema for `{cls_str}` is not currently implemented in Rust."
160            )));
161        }
162    };
163
164    arrow_record_batches_to_pybytes(py, &schema, &[])
165}
166
167/// Converts a vector of `OrderBookDelta` into an Arrow `RecordBatch`.
168#[pyfunction]
169#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
170#[expect(clippy::missing_panics_doc)] // Guarded by empty check
171pub fn pyobjects_to_arrow_record_batch_bytes(
172    py: Python,
173    data: Vec<Bound<'_, PyAny>>,
174) -> PyResult<Py<PyBytes>> {
175    if data.is_empty() {
176        return Err(to_pyvalue_err("Empty data"));
177    }
178
179    let data_type: String = data
180        .first()
181        .unwrap() // SAFETY: Unwrap safe as already checked that `data` not empty
182        .getattr("__class__")?
183        .getattr("__name__")?
184        .extract()?;
185
186    match data_type.as_str() {
187        stringify!(OrderBookDelta) => {
188            let deltas = pyobjects_to_book_deltas(data)?;
189            py_book_deltas_to_arrow_record_batch_bytes(py, deltas)
190        }
191        stringify!(OrderBookDepth) => {
192            let depth_snapshots: Vec<OrderBookDepth> = data
193                .into_iter()
194                .map(|obj| obj.extract::<OrderBookDepth>().map_err(Into::into))
195                .collect::<PyResult<Vec<OrderBookDepth>>>()?;
196            py_book_depths_to_arrow_record_batch_bytes(py, depth_snapshots)
197        }
198        stringify!(QuoteTick) => {
199            let quotes = pyobjects_to_quotes(data)?;
200            py_quotes_to_arrow_record_batch_bytes(py, quotes)
201        }
202        stringify!(TradeTick) => {
203            let trades = pyobjects_to_trades(data)?;
204            py_trades_to_arrow_record_batch_bytes(py, trades)
205        }
206        stringify!(Bar) => {
207            let bars = pyobjects_to_bars(data)?;
208            py_bars_to_arrow_record_batch_bytes(py, bars)
209        }
210        stringify!(MarkPriceUpdate) => {
211            let updates = pyobjects_to_mark_prices(data)?;
212            py_mark_prices_to_arrow_record_batch_bytes(py, updates)
213        }
214        stringify!(IndexPriceUpdate) => {
215            let index_prices = pyobjects_to_index_prices(data)?;
216            py_index_prices_to_arrow_record_batch_bytes(py, index_prices)
217        }
218        stringify!(InstrumentStatus) => {
219            let statuses = pyobjects_to_instrument_statuses(data)?;
220            py_instrument_status_to_arrow_record_batch_bytes(py, statuses)
221        }
222        stringify!(OptionGreeks) => {
223            let greeks = pyobjects_to_option_greeks(data)?;
224            py_option_greeks_to_arrow_record_batch_bytes(py, greeks)
225        }
226        stringify!(InstrumentClose) => {
227            let closes = pyobjects_to_instrument_closes(data)?;
228            py_instrument_closes_to_arrow_record_batch_bytes(py, closes)
229        }
230        _ => Err(to_pyvalue_err(format!(
231            "unsupported data type: {data_type}"
232        ))),
233    }
234}
235
236/// Converts a vector of `OrderBookDelta` into an Arrow `RecordBatch`.
237///
238/// # Errors
239///
240/// Returns an error if:
241/// - `data` is empty: `EncodingError::EmptyData`.
242/// - Instrument IDs differ, or non-clear precision metadata differs:
243///   `EncodingError::MixedMetadata`.
244/// - Encoding fails: `EncodingError::ArrowError`.
245#[pyfunction(name = "book_deltas_to_arrow_record_batch_bytes")]
246#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
247#[expect(clippy::needless_pass_by_value)]
248pub fn py_book_deltas_to_arrow_record_batch_bytes(
249    py: Python,
250    data: Vec<OrderBookDelta>,
251) -> PyResult<Py<PyBytes>> {
252    match book_deltas_to_arrow_record_batch_bytes(&data) {
253        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
254        Err(e) => Err(to_pyvalue_err(e)),
255    }
256}
257
258/// Converts a vector of `OrderBookDepth` into an Arrow `RecordBatch`.
259///
260/// # Errors
261///
262/// Returns an error if:
263/// - `data` is empty: `EncodingError::EmptyData`.
264/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
265/// - Encoding fails: `EncodingError::ArrowError`.
266#[pyfunction(name = "book_depths_to_arrow_record_batch_bytes")]
267#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
268#[expect(clippy::needless_pass_by_value)]
269pub fn py_book_depths_to_arrow_record_batch_bytes(
270    py: Python,
271    data: Vec<OrderBookDepth>,
272) -> PyResult<Py<PyBytes>> {
273    match book_depths_to_arrow_record_batch_bytes(&data) {
274        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
275        Err(e) => Err(to_pyvalue_err(e)),
276    }
277}
278
279/// Converts a vector of `QuoteTick` into an Arrow `RecordBatch`.
280///
281/// # Errors
282///
283/// Returns an error if:
284/// - `data` is empty: `EncodingError::EmptyData`.
285/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
286/// - Encoding fails: `EncodingError::ArrowError`.
287#[pyfunction(name = "quotes_to_arrow_record_batch_bytes")]
288#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
289#[expect(clippy::needless_pass_by_value)]
290pub fn py_quotes_to_arrow_record_batch_bytes(
291    py: Python,
292    data: Vec<QuoteTick>,
293) -> PyResult<Py<PyBytes>> {
294    match quotes_to_arrow_record_batch_bytes(&data) {
295        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
296        Err(e) => Err(to_pyvalue_err(e)),
297    }
298}
299
300/// Converts a vector of `TradeTick` into an Arrow `RecordBatch`.
301///
302/// # Errors
303///
304/// Returns an error if:
305/// - `data` is empty: `EncodingError::EmptyData`.
306/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
307/// - Encoding fails: `EncodingError::ArrowError`.
308#[pyfunction(name = "trades_to_arrow_record_batch_bytes")]
309#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
310#[expect(clippy::needless_pass_by_value)]
311pub fn py_trades_to_arrow_record_batch_bytes(
312    py: Python,
313    data: Vec<TradeTick>,
314) -> PyResult<Py<PyBytes>> {
315    match trades_to_arrow_record_batch_bytes(&data) {
316        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
317        Err(e) => Err(to_pyvalue_err(e)),
318    }
319}
320
321/// Converts a vector of `Bar` into an Arrow `RecordBatch`.
322///
323/// # Errors
324///
325/// Returns an error if:
326/// - `data` is empty: `EncodingError::EmptyData`.
327/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
328/// - Encoding fails: `EncodingError::ArrowError`.
329#[pyfunction(name = "bars_to_arrow_record_batch_bytes")]
330#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
331#[expect(clippy::needless_pass_by_value)]
332pub fn py_bars_to_arrow_record_batch_bytes(py: Python, data: Vec<Bar>) -> PyResult<Py<PyBytes>> {
333    match bars_to_arrow_record_batch_bytes(&data) {
334        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
335        Err(e) => Err(to_pyvalue_err(e)),
336    }
337}
338
339/// Converts a vector of `MarkPriceUpdate` into an Arrow `RecordBatch`.
340///
341/// # Errors
342///
343/// Returns an error if:
344/// - `data` is empty: `EncodingError::EmptyData`.
345/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
346/// - Encoding fails: `EncodingError::ArrowError`.
347#[pyfunction(name = "mark_prices_to_arrow_record_batch_bytes")]
348#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
349#[expect(clippy::needless_pass_by_value)]
350pub fn py_mark_prices_to_arrow_record_batch_bytes(
351    py: Python,
352    data: Vec<MarkPriceUpdate>,
353) -> PyResult<Py<PyBytes>> {
354    match mark_prices_to_arrow_record_batch_bytes(&data) {
355        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
356        Err(e) => Err(to_pyvalue_err(e)),
357    }
358}
359
360/// Converts a vector of `IndexPriceUpdate` into an Arrow `RecordBatch`.
361///
362/// # Errors
363///
364/// Returns an error if:
365/// - `data` is empty: `EncodingError::EmptyData`.
366/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
367/// - Encoding fails: `EncodingError::ArrowError`.
368#[pyfunction(name = "index_prices_to_arrow_record_batch_bytes")]
369#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
370#[expect(clippy::needless_pass_by_value)]
371pub fn py_index_prices_to_arrow_record_batch_bytes(
372    py: Python,
373    data: Vec<IndexPriceUpdate>,
374) -> PyResult<Py<PyBytes>> {
375    match index_prices_to_arrow_record_batch_bytes(&data) {
376        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
377        Err(e) => Err(to_pyvalue_err(e)),
378    }
379}
380
381/// Converts a vector of `InstrumentStatus` into an Arrow `RecordBatch`.
382///
383/// # Errors
384///
385/// Returns an error if:
386/// - `data` is empty: `EncodingError::EmptyData`.
387/// - Encoding fails: `EncodingError::ArrowError`.
388#[pyfunction(name = "instrument_status_to_arrow_record_batch_bytes")]
389#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
390#[expect(clippy::needless_pass_by_value)]
391pub fn py_instrument_status_to_arrow_record_batch_bytes(
392    py: Python,
393    data: Vec<InstrumentStatus>,
394) -> PyResult<Py<PyBytes>> {
395    match instrument_status_to_arrow_record_batch_bytes(&data) {
396        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
397        Err(e) => Err(to_pyvalue_err(e)),
398    }
399}
400
401/// Converts a vector of `OptionGreeks` into an Arrow `RecordBatch`.
402///
403/// # Errors
404///
405/// Returns an error if:
406/// - `data` is empty: `EncodingError::EmptyData`.
407/// - Encoding fails: `EncodingError::ArrowError`.
408#[pyfunction(name = "option_greeks_to_arrow_record_batch_bytes")]
409#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
410#[expect(clippy::needless_pass_by_value)]
411pub fn py_option_greeks_to_arrow_record_batch_bytes(
412    py: Python,
413    data: Vec<OptionGreeks>,
414) -> PyResult<Py<PyBytes>> {
415    match option_greeks_to_arrow_record_batch_bytes(&data) {
416        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
417        Err(e) => Err(to_pyvalue_err(e)),
418    }
419}
420
421/// Decodes Arrow IPC bytes into a list of `OptionGreeks`.
422///
423/// # Errors
424///
425/// Returns a `PyErr` if decoding fails.
426#[pyfunction(name = "option_greeks_from_arrow_record_batch_bytes")]
427#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
428pub fn py_option_greeks_from_arrow_record_batch_bytes(
429    _py: Python,
430    data: Vec<u8>,
431) -> PyResult<Vec<OptionGreeks>> {
432    let cursor = Cursor::new(data);
433    let reader = StreamReader::try_new(cursor, None).map_err(to_pyruntime_err)?;
434
435    let mut results = Vec::new();
436
437    for batch_result in reader {
438        let batch = batch_result.map_err(to_pyruntime_err)?;
439        let metadata = batch.schema().metadata().clone();
440        let decoded = OptionGreeks::decode_batch(&metadata, batch).map_err(to_pyvalue_err)?;
441        results.extend(decoded);
442    }
443
444    Ok(results)
445}
446
447/// Decodes Arrow IPC bytes into a list of `InstrumentStatus`.
448///
449/// # Errors
450///
451/// Returns a `PyErr` if decoding fails.
452#[pyfunction(name = "instrument_status_from_arrow_record_batch_bytes")]
453#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
454pub fn py_instrument_status_from_arrow_record_batch_bytes(
455    _py: Python,
456    data: Vec<u8>,
457) -> PyResult<Vec<InstrumentStatus>> {
458    let cursor = Cursor::new(data);
459    let reader = StreamReader::try_new(cursor, None).map_err(to_pyruntime_err)?;
460
461    let mut results = Vec::new();
462
463    for batch_result in reader {
464        let batch = batch_result.map_err(to_pyruntime_err)?;
465        let metadata = batch.schema().metadata().clone();
466        let decoded =
467            InstrumentStatus::decode_typed_batch(&metadata, batch).map_err(to_pyvalue_err)?;
468        results.extend(decoded);
469    }
470
471    Ok(results)
472}
473
474/// Converts a vector of `InstrumentClose` into an Arrow `RecordBatch`.
475///
476/// # Errors
477///
478/// Returns an error if:
479/// - `data` is empty: `EncodingError::EmptyData`.
480/// - Metadata differs between rows: `EncodingError::MixedMetadata`.
481/// - Encoding fails: `EncodingError::ArrowError`.
482#[pyfunction(name = "instrument_closes_to_arrow_record_batch_bytes")]
483#[pyo3_stub_gen::derive::gen_stub_pyfunction(module = "nautilus_trader.serialization")]
484#[expect(clippy::needless_pass_by_value)]
485pub fn py_instrument_closes_to_arrow_record_batch_bytes(
486    py: Python,
487    data: Vec<InstrumentClose>,
488) -> PyResult<Py<PyBytes>> {
489    match instrument_closes_to_arrow_record_batch_bytes(&data) {
490        Ok(batch) => arrow_record_batch_to_pybytes(py, &batch),
491        Err(e) => Err(to_pyvalue_err(e)),
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use std::collections::HashMap;
498
499    use nautilus_model::data::stubs::stub_instrument_status;
500    use pyo3::{
501        exceptions::{PyRuntimeError, PyTypeError, PyValueError},
502        types::PyString,
503    };
504    use rstest::rstest;
505
506    use super::*;
507    use crate::arrow::EncodeToRecordBatch;
508
509    #[rstest]
510    fn test_schema_bytes_and_map_match_rust_schema() {
511        Python::initialize();
512        Python::attach(|py| {
513            let cls = py.get_type::<InstrumentStatus>();
514            let bytes = get_arrow_schema_bytes(py, &cls).unwrap();
515            let mut reader = StreamReader::try_new(Cursor::new(bytes.as_bytes(py)), None).unwrap();
516            let map: HashMap<String, String> =
517                get_arrow_schema_map(py, &cls).unwrap().extract(py).unwrap();
518
519            assert_eq!(
520                reader.schema().as_ref(),
521                &InstrumentStatus::get_schema(None)
522            );
523            assert!(reader.next().is_none());
524            assert_eq!(map, InstrumentStatus::get_schema_map());
525        });
526    }
527
528    #[rstest]
529    fn test_schema_rejects_unsupported_class() {
530        Python::initialize();
531        Python::attach(|py| {
532            let cls = py.get_type::<PyString>();
533            let errors = [
534                get_arrow_schema_bytes(py, &cls).unwrap_err(),
535                get_arrow_schema_map(py, &cls).unwrap_err(),
536            ];
537
538            for error in errors {
539                assert!(error.is_instance_of::<PyTypeError>(py));
540                assert_eq!(
541                    error.value(py).to_string(),
542                    "Arrow schema for `str` is not currently implemented in Rust."
543                );
544            }
545        });
546    }
547
548    #[rstest]
549    fn test_status_ipc_preserves_multiple_batches() {
550        let first = stub_instrument_status();
551        let mut second = first;
552        second.ts_event = 31.into();
553        second.ts_init = 47.into();
554        second.reason = Some("venue halt".into());
555        second.is_trading = Some(false);
556        second.is_quoting = Some(true);
557        let rows = [first, second];
558        let batches = rows
559            .iter()
560            .map(|row| InstrumentStatus::encode_batch(&row.metadata(), &[*row]).unwrap())
561            .collect::<Vec<_>>();
562        Python::initialize();
563        Python::attach(|py| {
564            let bytes =
565                arrow_record_batches_to_pybytes(py, &batches[0].schema(), &batches).unwrap();
566            let decoded =
567                py_instrument_status_from_arrow_record_batch_bytes(py, bytes.as_bytes(py).to_vec())
568                    .unwrap();
569            let single =
570                py_instrument_status_to_arrow_record_batch_bytes(py, rows.to_vec()).unwrap();
571            let single_decoded = py_instrument_status_from_arrow_record_batch_bytes(
572                py,
573                single.as_bytes(py).to_vec(),
574            )
575            .unwrap();
576
577            assert_eq!(decoded, rows);
578            assert_eq!(single_decoded, rows);
579        });
580    }
581
582    #[rstest]
583    fn test_ipc_decode_rejects_invalid_stream() {
584        Python::initialize();
585        Python::attach(|py| {
586            let errors = [
587                py_instrument_status_from_arrow_record_batch_bytes(py, vec![1, 2, 3]).unwrap_err(),
588                py_option_greeks_from_arrow_record_batch_bytes(py, vec![1, 2, 3]).unwrap_err(),
589            ];
590
591            for error in errors {
592                assert!(error.is_instance_of::<PyRuntimeError>(py));
593                assert_eq!(
594                    error.value(py).to_string(),
595                    "Ipc error: Expected schema message, found empty stream."
596                );
597            }
598        });
599    }
600
601    #[rstest]
602    fn test_python_encoders_reject_empty_data() {
603        Python::initialize();
604        Python::attach(|py| {
605            let errors = [
606                py_book_deltas_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
607                py_book_depths_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
608                py_quotes_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
609                py_trades_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
610                py_bars_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
611                py_mark_prices_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
612                py_index_prices_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
613                py_instrument_status_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
614                py_option_greeks_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
615                py_instrument_closes_to_arrow_record_batch_bytes(py, vec![]).unwrap_err(),
616            ];
617
618            for error in errors {
619                assert!(error.is_instance_of::<PyValueError>(py));
620                assert_eq!(
621                    error.value(py).to_string(),
622                    crate::arrow::EncodingError::EmptyData.to_string()
623                );
624            }
625        });
626    }
627}