Skip to main content

nautilus_serialization/arrow/instrument/
mod.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
16//! Arrow serialization for instruments.
17//!
18//! `InstrumentAny` acts as a dispatcher that routes to the appropriate concrete instrument type's
19//! Arrow serialization implementation. Each concrete instrument type implements its own schema
20//! with all fields as columns (wide schema approach), matching the Python implementation.
21
22use std::{any::type_name, collections::HashMap, fmt, str::FromStr};
23
24use arrow::{
25    array::{Array, StringArray},
26    datatypes::Schema,
27    error::ArrowError,
28    record_batch::RecordBatch,
29};
30use nautilus_model::{
31    instruments::{
32        Instrument, InstrumentAny, betting::BettingInstrument, binary_option::BinaryOption,
33        cfd::Cfd, commodity::Commodity, crypto_future::CryptoFuture,
34        crypto_futures_spread::CryptoFuturesSpread, crypto_option::CryptoOption,
35        crypto_option_spread::CryptoOptionSpread, crypto_perpetual::CryptoPerpetual,
36        currency_pair::CurrencyPair, equity::Equity, futures_contract::FuturesContract,
37        futures_spread::FuturesSpread, index_instrument::IndexInstrument,
38        option_contract::OptionContract, option_spread::OptionSpread,
39        perpetual_contract::PerpetualContract, tokenized_asset::TokenizedAsset,
40    },
41    types::{Currency, Price, Quantity},
42};
43
44use crate::arrow::{ArrowSchemaProvider, EncodeToRecordBatch, EncodingError, KEY_INSTRUMENT_ID};
45
46pub mod betting;
47pub mod binary_option;
48pub mod cfd;
49pub mod commodity;
50pub mod crypto_future;
51pub mod crypto_futures_spread;
52pub mod crypto_option;
53pub mod crypto_option_spread;
54pub mod crypto_perpetual;
55pub mod currency_pair;
56pub mod equity;
57pub mod futures_contract;
58pub mod futures_spread;
59pub mod index_instrument;
60pub mod option_contract;
61pub mod option_spread;
62pub mod perpetual_contract;
63pub mod tokenized_asset;
64
65// Columns added after the original schemas are read by name and yield `None` when absent,
66// so fragments written before the column existed decode exactly as they did previously.
67pub(crate) fn optional_quantity_value(
68    values: Option<&StringArray>,
69    field: &'static str,
70    row: usize,
71) -> Result<Option<Quantity>, EncodingError> {
72    let Some(column) = values else {
73        return Ok(None);
74    };
75
76    if column.is_null(row) {
77        return Ok(None);
78    }
79
80    Quantity::from_str(column.value(row))
81        .map(Some)
82        .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
83}
84
85pub(crate) fn optional_price_value(
86    values: Option<&StringArray>,
87    field: &'static str,
88    row: usize,
89) -> Result<Option<Price>, EncodingError> {
90    let Some(column) = values else {
91        return Ok(None);
92    };
93
94    if column.is_null(row) {
95        return Ok(None);
96    }
97
98    Price::from_str(column.value(row))
99        .map(Some)
100        .map_err(|e| EncodingError::ParseError(field, format!("row {row}: {e}")))
101}
102
103// Errors on empty/whitespace codes so corrupted rows surface as ParseError,
104// instead of silently registering as a fallback currency. Known codes resolve
105// from CURRENCY_MAP with original metadata; unknown non-empty codes fall back
106// to a new crypto currency to support newly listed exchange assets.
107pub(crate) fn decode_currency(
108    value: &str,
109    field: &'static str,
110    context: &'static str,
111    row: usize,
112) -> Result<Currency, EncodingError> {
113    let trimmed = value.trim();
114    if trimmed.is_empty() {
115        return Err(EncodingError::ParseError(
116            field,
117            format!("row {row}: empty currency code"),
118        ));
119    }
120
121    Ok(Currency::get_or_create_crypto_with_context(
122        trimmed,
123        Some(context),
124    ))
125}
126
127pub(crate) const KEY_CLASS: &str = "class";
128
129const INSTRUMENT_VALIDATION_FIELD: &str = "instrument";
130
131pub(crate) fn instrument_validation_error<T>(
132    row: usize,
133    error: impl fmt::Display,
134) -> EncodingError {
135    let type_name = type_name::<T>();
136    let instrument_type = type_name.rsplit("::").next().unwrap_or(type_name);
137
138    EncodingError::ParseError(
139        INSTRUMENT_VALIDATION_FIELD,
140        format!("row {row}: invalid {instrument_type}: {error}"),
141    )
142}
143
144/// Wires every [`InstrumentAny`] variant into the Arrow schema, encode, and decode paths from a
145/// single table of `(variant, instrument type, class name, decode function)` rows.
146macro_rules! impl_instrument_any_arrow {
147    ($(($variant:ident, $instrument:ty, $class:literal, $decode:path)),+ $(,)?) => {
148        impl ArrowSchemaProvider for InstrumentAny {
149            fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema {
150                let class = metadata
151                    .as_ref()
152                    .and_then(|metadata| metadata.get(KEY_CLASS))
153                    .map(String::as_str);
154
155                match class {
156                    $(Some($class) => <$instrument>::get_schema(metadata),)+
157                    // Batches written without a class, or with one this build does not know,
158                    // decode against the `CurrencyPair` schema; that was the only instrument
159                    // schema when the column was introduced.
160                    _ => CurrencyPair::get_schema(metadata),
161                }
162            }
163        }
164
165        impl EncodeToRecordBatch for InstrumentAny {
166            fn encode_batch(
167                metadata: &HashMap<String, String>,
168                data: &[Self],
169            ) -> Result<RecordBatch, ArrowError> {
170                let Some(first) = data.first() else {
171                    return Err(ArrowError::InvalidArgumentError(
172                        "Cannot encode empty instrument batch".to_string(),
173                    ));
174                };
175
176                match first {
177                    $(Self::$variant(_) => {
178                        let mut instruments = Vec::with_capacity(data.len());
179
180                        for instrument in data {
181                            let Self::$variant(instrument) = instrument else {
182                                return Err(mixed_instrument_types());
183                            };
184
185                            instruments.push(instrument.clone());
186                        }
187
188                        <$instrument>::encode_batch(metadata, &instruments)
189                    })+
190                }
191            }
192
193            fn metadata(&self) -> HashMap<String, String> {
194                let class = match self {
195                    $(Self::$variant(_) => $class,)+
196                };
197
198                HashMap::from([
199                    (KEY_INSTRUMENT_ID.to_string(), Instrument::id(self).to_string()),
200                    (KEY_CLASS.to_string(), class.to_string()),
201                ])
202            }
203        }
204
205        fn decode_batch_for_class(
206            class: &str,
207            metadata: &HashMap<String, String>,
208            record_batch: &RecordBatch,
209        ) -> Result<Vec<InstrumentAny>, EncodingError> {
210            match class {
211                $($class => Ok($decode(metadata, record_batch)?
212                    .into_iter()
213                    .map(InstrumentAny::$variant)
214                    .collect()),)+
215                _ => Err(EncodingError::ParseError(
216                    KEY_CLASS,
217                    format!("Unknown instrument type: {class}"),
218                )),
219            }
220        }
221    };
222}
223
224fn mixed_instrument_types() -> ArrowError {
225    ArrowError::InvalidArgumentError(
226        "Cannot encode mixed instrument types in a single batch. Use separate batches for each type."
227            .to_string(),
228    )
229}
230
231impl_instrument_any_arrow!(
232    (
233        Betting,
234        BettingInstrument,
235        "BettingInstrument",
236        betting::decode_betting_instrument_batch
237    ),
238    (
239        BinaryOption,
240        BinaryOption,
241        "BinaryOption",
242        binary_option::decode_binary_option_batch
243    ),
244    (Cfd, Cfd, "Cfd", cfd::decode_cfd_batch),
245    (
246        Commodity,
247        Commodity,
248        "Commodity",
249        commodity::decode_commodity_batch
250    ),
251    (
252        CryptoFuture,
253        CryptoFuture,
254        "CryptoFuture",
255        crypto_future::decode_crypto_future_batch
256    ),
257    (
258        CryptoFuturesSpread,
259        CryptoFuturesSpread,
260        "CryptoFuturesSpread",
261        crypto_futures_spread::decode_crypto_futures_spread_batch
262    ),
263    (
264        CryptoOption,
265        CryptoOption,
266        "CryptoOption",
267        crypto_option::decode_crypto_option_batch
268    ),
269    (
270        CryptoOptionSpread,
271        CryptoOptionSpread,
272        "CryptoOptionSpread",
273        crypto_option_spread::decode_crypto_option_spread_batch
274    ),
275    (
276        CryptoPerpetual,
277        CryptoPerpetual,
278        "CryptoPerpetual",
279        crypto_perpetual::decode_crypto_perpetual_batch
280    ),
281    (
282        CurrencyPair,
283        CurrencyPair,
284        "CurrencyPair",
285        currency_pair::decode_currency_pair_batch
286    ),
287    (Equity, Equity, "Equity", equity::decode_equity_batch),
288    (
289        FuturesContract,
290        FuturesContract,
291        "FuturesContract",
292        futures_contract::decode_futures_contract_batch
293    ),
294    (
295        FuturesSpread,
296        FuturesSpread,
297        "FuturesSpread",
298        futures_spread::decode_futures_spread_batch
299    ),
300    (
301        IndexInstrument,
302        IndexInstrument,
303        "IndexInstrument",
304        index_instrument::decode_index_instrument_batch
305    ),
306    (
307        OptionContract,
308        OptionContract,
309        "OptionContract",
310        option_contract::decode_option_contract_batch
311    ),
312    (
313        OptionSpread,
314        OptionSpread,
315        "OptionSpread",
316        option_spread::decode_option_spread_batch
317    ),
318    (
319        PerpetualContract,
320        PerpetualContract,
321        "PerpetualContract",
322        perpetual_contract::decode_perpetual_contract_batch
323    ),
324    (
325        TokenizedAsset,
326        TokenizedAsset,
327        "TokenizedAsset",
328        tokenized_asset::decode_tokenized_asset_batch
329    ),
330);
331
332/// Decodes `InstrumentAny` values from a record batch.
333///
334/// Not a [`DecodeFromRecordBatch`] implementation because that trait requires `Into<Data>`.
335///
336/// # Errors
337///
338/// Returns an `EncodingError` if the record batch cannot be decoded.
339///
340/// [`DecodeFromRecordBatch`]: crate::arrow::DecodeFromRecordBatch
341pub fn decode_instrument_any_batch(
342    metadata: &HashMap<String, String>,
343    record_batch: &RecordBatch,
344) -> Result<Vec<InstrumentAny>, EncodingError> {
345    let class = metadata
346        .get(KEY_CLASS)
347        .map(String::as_str)
348        .ok_or(EncodingError::MissingMetadata(KEY_CLASS))?;
349
350    decode_batch_for_class(class, metadata, record_batch)
351}
352
353#[cfg(test)]
354mod tests {
355    use std::sync::Arc;
356
357    use arrow::array::{ArrayRef, StringArray, UInt8Array};
358    use nautilus_core::{Params, UnixNanos};
359    use nautilus_model::{
360        enums::{AssetClass, CurrencyType, OptionKind},
361        identifiers::{InstrumentId, Symbol},
362        instruments::{
363            Instrument, InstrumentAny,
364            currency_pair::CurrencyPair,
365            stubs::{betting, currency_pair_btcusdt, equity_aapl},
366        },
367        types::{Currency, Money, Price, Quantity},
368    };
369    use rstest::rstest;
370    use rust_decimal_macros::dec;
371    use ustr::Ustr;
372
373    use super::*;
374
375    #[rstest]
376    fn test_get_schema() {
377        let mut metadata = HashMap::new();
378        metadata.insert(KEY_CLASS.to_string(), "CurrencyPair".to_string());
379        let schema = InstrumentAny::get_schema(Some(metadata));
380        assert!(schema.fields().len() >= 20);
381        assert_eq!(schema.field(0).name(), "id");
382    }
383
384    #[rstest]
385    fn test_encode_batch_rejects_mixed_instrument_types() {
386        let instruments = [
387            InstrumentAny::CurrencyPair(currency_pair_btcusdt()),
388            InstrumentAny::Equity(equity_aapl()),
389        ];
390
391        let error = InstrumentAny::encode_batch(&HashMap::new(), &instruments).unwrap_err();
392
393        let ArrowError::InvalidArgumentError(message) = error else {
394            panic!("unexpected error variant: {error:?}");
395        };
396        assert_eq!(
397            message,
398            "Cannot encode mixed instrument types in a single batch. Use separate batches for each type."
399        );
400    }
401
402    #[rstest]
403    #[case("")]
404    #[case("   ")]
405    #[case("\t\n")]
406    fn test_decode_currency_empty_or_whitespace_errors(#[case] value: &str) {
407        let result = decode_currency(value, "currency", "test.currency", 7);
408        let err = result.expect_err("empty code must surface EncodingError");
409        match err {
410            EncodingError::ParseError(field, msg) => {
411                assert_eq!(field, "currency");
412                assert!(
413                    msg.contains("row 7"),
414                    "message should include row index, found: {msg}",
415                );
416                assert!(
417                    msg.contains("empty currency code"),
418                    "message should describe empty code, found: {msg}",
419                );
420            }
421            other => panic!("unexpected error variant: {other:?}"),
422        }
423        // Ensure the fallback did not register a phantom currency under the empty key.
424        assert!(Currency::try_from_str(value.trim()).is_none());
425    }
426
427    #[rstest]
428    #[case("USD", CurrencyType::Fiat, 2)]
429    #[case("BTC", CurrencyType::Crypto, 8)]
430    #[case("XAU", CurrencyType::CommodityBacked, 2)]
431    fn test_decode_currency_known_code_preserves_metadata(
432        #[case] code: &str,
433        #[case] expected_type: CurrencyType,
434        #[case] expected_precision: u8,
435    ) {
436        let currency = decode_currency(code, "currency", "test.currency", 0).unwrap();
437        assert_eq!(currency.code, code);
438        assert_eq!(currency.currency_type, expected_type);
439        assert_eq!(currency.precision, expected_precision);
440    }
441
442    #[rstest]
443    fn test_decode_currency_unknown_code_registers_as_crypto() {
444        let code = "XDECTEST";
445        assert!(
446            Currency::try_from_str(code).is_none(),
447            "test precondition: '{code}' must not be pre-registered",
448        );
449
450        let currency = decode_currency(code, "base_currency", "test.base_currency", 0).unwrap();
451        assert_eq!(currency.code, code);
452        assert_eq!(currency.currency_type, CurrencyType::Crypto);
453        assert_eq!(currency.precision, 8);
454        assert_eq!(currency.iso4217, 0);
455
456        let registered = Currency::try_from_str(code).expect("unknown code must be registered");
457        assert_eq!(registered, currency);
458    }
459
460    #[rstest]
461    fn test_encode_decode_round_trip() {
462        let instrument_id = InstrumentId::from("EUR/USD.SIM");
463        let currency_pair = CurrencyPair::builder()
464            .instrument_id(instrument_id)
465            .raw_symbol(Symbol::from("EUR/USD"))
466            .base_currency(Currency::from("EUR"))
467            .quote_currency(Currency::from("USD"))
468            .price_precision(5)
469            // size_precision must match size_increment precision (0)
470            .size_precision(0)
471            .price_increment(Price::new(0.00001, 5))
472            // precision 0
473            .size_increment(Quantity::new(1.0, 0))
474            .tick_scheme(Ustr::from("FOREX_5DECIMAL"))
475            .ts_event(UnixNanos::default())
476            .ts_init(UnixNanos::default())
477            .build()
478            .unwrap();
479        let instrument = InstrumentAny::CurrencyPair(currency_pair);
480
481        let metadata = instrument.metadata();
482        let record_batch =
483            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
484        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
485
486        assert_eq!(decoded.len(), 1);
487        assert_eq!(Instrument::id(&decoded[0]), Instrument::id(&instrument));
488        assert_eq!(
489            Instrument::raw_symbol(&decoded[0]),
490            Instrument::raw_symbol(&instrument)
491        );
492        assert_eq!(
493            Instrument::asset_class(&decoded[0]),
494            Instrument::asset_class(&instrument)
495        );
496
497        match (&decoded[0], &instrument) {
498            (InstrumentAny::CurrencyPair(decoded_cp), InstrumentAny::CurrencyPair(original_cp)) => {
499                assert_eq!(decoded_cp.id, original_cp.id);
500                assert_eq!(decoded_cp.base_currency, original_cp.base_currency);
501                assert_eq!(decoded_cp.quote_currency, original_cp.quote_currency);
502                assert_eq!(decoded_cp.price_precision, original_cp.price_precision);
503                assert_eq!(decoded_cp.size_precision, original_cp.size_precision);
504                assert_eq!(decoded_cp.tick_scheme, original_cp.tick_scheme);
505            }
506            _ => panic!("Decoded instrument type mismatch"),
507        }
508    }
509
510    #[rstest]
511    fn test_decode_currency_pair_without_tick_scheme_column_defaults_none() {
512        let instrument_id = InstrumentId::from("EUR/USD.SIM");
513        let currency_pair = CurrencyPair::builder()
514            .instrument_id(instrument_id)
515            .raw_symbol(Symbol::from("EUR/USD"))
516            .base_currency(Currency::from("EUR"))
517            .quote_currency(Currency::from("USD"))
518            .price_precision(5)
519            .size_precision(0)
520            .price_increment(Price::new(0.00001, 5))
521            .size_increment(Quantity::new(1.0, 0))
522            .tick_scheme(Ustr::from("FOREX_5DECIMAL"))
523            .ts_event(UnixNanos::default())
524            .ts_init(UnixNanos::default())
525            .build()
526            .unwrap();
527        let instrument = InstrumentAny::CurrencyPair(currency_pair);
528
529        let metadata = instrument.metadata();
530        let record_batch =
531            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
532        let record_batch = batch_without_column(&record_batch, "tick_scheme");
533        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
534
535        assert_eq!(decoded.len(), 1);
536        match &decoded[0] {
537            InstrumentAny::CurrencyPair(decoded_cp) => {
538                assert_eq!(decoded_cp.id, instrument.id());
539                assert_eq!(decoded_cp.tick_scheme, None);
540            }
541            _ => panic!("Decoded instrument type mismatch"),
542        }
543    }
544
545    #[rstest]
546    fn test_encode_decode_round_trip_equity() {
547        use nautilus_model::instruments::{Instrument, equity::Equity};
548
549        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
550        let equity = Equity::builder()
551            .instrument_id(instrument_id)
552            .raw_symbol(Symbol::from("AAPL"))
553            .currency(Currency::from("USD"))
554            .price_precision(2)
555            .price_increment(Price::new(0.01, 2))
556            .ts_event(UnixNanos::default())
557            .ts_init(UnixNanos::default())
558            .build()
559            .unwrap();
560        let instrument = InstrumentAny::Equity(equity);
561
562        let metadata = instrument.metadata();
563        let record_batch =
564            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
565        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
566        assert_eq!(decoded.len(), 1);
567        assert_eq!(Instrument::id(&decoded[0]), Instrument::id(&instrument));
568        assert_eq!(
569            Instrument::raw_symbol(&decoded[0]),
570            Instrument::raw_symbol(&instrument)
571        );
572        assert_eq!(
573            Instrument::asset_class(&decoded[0]),
574            Instrument::asset_class(&instrument)
575        );
576
577        match (&decoded[0], &instrument) {
578            (InstrumentAny::Equity(decoded_eq), InstrumentAny::Equity(original_eq)) => {
579                assert_eq!(decoded_eq.id, original_eq.id);
580                assert_eq!(decoded_eq.currency, original_eq.currency);
581                assert_eq!(decoded_eq.price_precision, original_eq.price_precision);
582            }
583            _ => panic!("Decoded instrument type mismatch"),
584        }
585    }
586
587    #[rstest]
588    fn test_encode_decode_round_trip_equity_all_fields() {
589        use nautilus_core::Params;
590
591        let mut info = Params::new();
592        info.insert("sector".to_string(), serde_json::json!("technology"));
593
594        let equity = Equity::builder()
595            .instrument_id(InstrumentId::from("AAPL.NASDAQ"))
596            .raw_symbol(Symbol::from("AAPL"))
597            .isin(Ustr::from("US0378331005"))
598            .currency(Currency::from("USD"))
599            .price_precision(2)
600            .price_increment(Price::from("0.01"))
601            .lot_size(Quantity::from("100"))
602            .max_quantity(Quantity::from("10000"))
603            .min_quantity(Quantity::from("1"))
604            .max_price(Price::from("9999.99"))
605            .min_price(Price::from("0.01"))
606            .margin_init(dec!(0.01))
607            .margin_maint(dec!(0.02))
608            .maker_fee(dec!(0.0002))
609            .taker_fee(dec!(0.0004))
610            .tick_scheme(Ustr::from("TOPIX100"))
611            .info(info)
612            .ts_event(1.into())
613            .ts_init(2.into())
614            .build()
615            .unwrap();
616        let instrument = InstrumentAny::Equity(equity.clone());
617
618        let metadata = instrument.metadata();
619        let record_batch =
620            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
621        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
622
623        assert_eq!(decoded.len(), 1);
624        let InstrumentAny::Equity(decoded_equity) = &decoded[0] else {
625            panic!("Decoded instrument type mismatch");
626        };
627
628        // The v1 `from_dict` dropped these quantity constraints (#4461), so check them here
629        assert_eq!(decoded_equity.max_quantity, equity.max_quantity);
630        assert_eq!(decoded_equity.min_quantity, equity.min_quantity);
631
632        // `PartialEq` compares only `id`, so compare every field via its serialized form
633        assert_eq!(
634            serde_json::to_value(decoded_equity).unwrap(),
635            serde_json::to_value(&equity).unwrap(),
636        );
637    }
638
639    #[rstest]
640    fn test_encode_decode_round_trip_futures_contract_all_fields() {
641        let contract = FuturesContract::builder()
642            .instrument_id(InstrumentId::from("ESZ4.XCME"))
643            .raw_symbol(Symbol::from("ESZ4"))
644            .asset_class(AssetClass::Index)
645            .exchange(Ustr::from("XCME"))
646            .underlying(Ustr::from("ES"))
647            .activation_ns(1.into())
648            .expiration_ns(2.into())
649            .currency(Currency::from("USD"))
650            .price_precision(2)
651            .price_increment(Price::from("0.01"))
652            .multiplier(Quantity::from("1"))
653            .lot_size(Quantity::from("1"))
654            .max_quantity(Quantity::from("10000"))
655            .min_quantity(Quantity::from("5"))
656            .max_price(Price::from("9999.99"))
657            .min_price(Price::from("0.01"))
658            .margin_init(dec!(0.01))
659            .margin_maint(dec!(0.02))
660            .maker_fee(dec!(0.0002))
661            .taker_fee(dec!(0.0004))
662            .ts_event(1.into())
663            .ts_init(2.into())
664            .build()
665            .unwrap();
666
667        let decoded = encode_decode_instrument(&InstrumentAny::FuturesContract(contract.clone()));
668        let InstrumentAny::FuturesContract(decoded) = decoded else {
669            panic!("Decoded instrument type mismatch");
670        };
671
672        assert_eq!(
673            serde_json::to_value(&decoded).unwrap(),
674            serde_json::to_value(&contract).unwrap(),
675        );
676    }
677
678    #[rstest]
679    fn test_encode_decode_round_trip_option_contract_all_fields() {
680        let contract = OptionContract::builder()
681            .instrument_id(InstrumentId::from("AAPL_C100.OPRA"))
682            .raw_symbol(Symbol::from("AAPL_C100"))
683            .asset_class(AssetClass::Equity)
684            .exchange(Ustr::from("OPRA"))
685            .underlying(Ustr::from("AAPL"))
686            .option_kind(OptionKind::Call)
687            .strike_price(Price::from("100.00"))
688            .currency(Currency::from("USD"))
689            .activation_ns(1.into())
690            .expiration_ns(2.into())
691            .price_precision(2)
692            .price_increment(Price::from("0.01"))
693            .multiplier(Quantity::from("100"))
694            .lot_size(Quantity::from("1"))
695            .max_quantity(Quantity::from("10000"))
696            .min_quantity(Quantity::from("5"))
697            .max_price(Price::from("9999.99"))
698            .min_price(Price::from("0.01"))
699            .margin_init(dec!(0.01))
700            .margin_maint(dec!(0.02))
701            .maker_fee(dec!(0.0002))
702            .taker_fee(dec!(0.0004))
703            .ts_event(1.into())
704            .ts_init(2.into())
705            .build()
706            .unwrap();
707
708        let decoded = encode_decode_instrument(&InstrumentAny::OptionContract(contract.clone()));
709        let InstrumentAny::OptionContract(decoded) = decoded else {
710            panic!("Decoded instrument type mismatch");
711        };
712
713        assert_eq!(
714            serde_json::to_value(&decoded).unwrap(),
715            serde_json::to_value(&contract).unwrap(),
716        );
717    }
718
719    #[rstest]
720    fn test_encode_decode_round_trip_binary_option_all_fields() {
721        let mut info = Params::new();
722        let raw = "0.1234567890123456789012345678";
723        info.insert("gamma_market".to_string(), serde_json::json!(raw));
724        let option = BinaryOption::builder()
725            .instrument_id(InstrumentId::from("ELECTION.POLYMARKET"))
726            .raw_symbol(Symbol::from("ELECTION"))
727            .asset_class(AssetClass::Alternative)
728            .currency(Currency::from("USDC"))
729            .activation_ns(1.into())
730            .expiration_ns(2.into())
731            .price_precision(2)
732            .size_precision(0)
733            .price_increment(Price::from("0.01"))
734            .size_increment(Quantity::from("1"))
735            .event_id(Ustr::from("event-123"))
736            .info(info)
737            .outcome(Ustr::from("YES"))
738            .description(Ustr::from("Election outcome"))
739            .max_quantity(Quantity::from("10000"))
740            .min_quantity(Quantity::from("5"))
741            .max_notional(Money::from("50000 USDC"))
742            .min_notional(Money::from("5 USDC"))
743            .max_price(Price::from("0.99"))
744            .min_price(Price::from("0.01"))
745            .margin_init(dec!(0.01))
746            .margin_maint(dec!(0.02))
747            .maker_fee(dec!(0.0002))
748            .taker_fee(dec!(0.0004))
749            .ts_event(1.into())
750            .ts_init(2.into())
751            .build()
752            .unwrap();
753
754        let decoded = encode_decode_instrument(&InstrumentAny::BinaryOption(option.clone()));
755        let InstrumentAny::BinaryOption(decoded) = decoded else {
756            panic!("Decoded instrument type mismatch");
757        };
758
759        assert_eq!(
760            serde_json::to_value(&decoded).unwrap(),
761            serde_json::to_value(&option).unwrap(),
762        );
763    }
764
765    #[rstest]
766    #[case::missing(false)]
767    #[case::null(true)]
768    fn test_binary_option_event_id_legacy_default(#[case] null: bool) {
769        let mut option = nautilus_model::instruments::stubs::binary_option();
770        option.event_id = Some(Ustr::from("event-123"));
771        let metadata = option.metadata();
772        let batch = BinaryOption::encode_batch(&metadata, &[option]).unwrap();
773
774        let batch = if null {
775            batch_with_null_string_column(&batch, "event_id")
776        } else {
777            batch_without_column(&batch, "event_id")
778        };
779
780        let decoded = binary_option::decode_binary_option_batch(&metadata, &batch).unwrap();
781        assert_eq!(decoded.len(), 1);
782        assert_eq!(decoded[0].event_id, None);
783    }
784
785    // The `betting` stub populates every bound, margin, and fee, so this covers the whole struct
786    #[rstest]
787    fn test_encode_decode_round_trip_betting_all_fields() {
788        let instrument = betting();
789
790        let decoded = encode_decode_instrument(&InstrumentAny::Betting(instrument.clone()));
791        let InstrumentAny::Betting(decoded) = decoded else {
792            panic!("Decoded instrument type mismatch");
793        };
794
795        assert_eq!(
796            serde_json::to_value(&decoded).unwrap(),
797            serde_json::to_value(&instrument).unwrap(),
798        );
799    }
800
801    fn encode_decode_instrument(instrument: &InstrumentAny) -> InstrumentAny {
802        let metadata = instrument.metadata();
803        let record_batch =
804            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(instrument)).unwrap();
805        let mut decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
806
807        assert_eq!(decoded.len(), 1);
808        decoded.remove(0)
809    }
810
811    fn roundtrip_case(instrument: &InstrumentAny) {
812        let metadata = instrument.metadata();
813        let record_batch =
814            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(instrument)).unwrap();
815        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
816
817        assert_eq!(decoded.len(), 1);
818        assert_eq!(Instrument::id(&decoded[0]), Instrument::id(instrument));
819        assert_eq!(
820            Instrument::raw_symbol(&decoded[0]),
821            Instrument::raw_symbol(instrument)
822        );
823        assert_eq!(
824            Instrument::asset_class(&decoded[0]),
825            Instrument::asset_class(instrument)
826        );
827        assert_eq!(
828            Instrument::instrument_class(&decoded[0]),
829            Instrument::instrument_class(instrument)
830        );
831        assert_eq!(
832            Instrument::price_precision(&decoded[0]),
833            Instrument::price_precision(instrument)
834        );
835        assert_eq!(
836            Instrument::size_precision(&decoded[0]),
837            Instrument::size_precision(instrument)
838        );
839        assert_eq!(
840            Instrument::quote_currency(&decoded[0]),
841            Instrument::quote_currency(instrument)
842        );
843        assert_eq!(
844            std::mem::discriminant(&decoded[0]),
845            std::mem::discriminant(instrument),
846            "decoded variant must match encoded variant"
847        );
848    }
849
850    fn batch_without_column(record_batch: &RecordBatch, column_name: &str) -> RecordBatch {
851        let schema = record_batch.schema();
852        let column_index = schema.index_of(column_name).unwrap();
853        let fields: Vec<_> = schema
854            .fields()
855            .iter()
856            .enumerate()
857            .filter(|(index, _)| *index != column_index)
858            .map(|(_, field)| field.as_ref().clone())
859            .collect();
860        let columns = record_batch
861            .columns()
862            .iter()
863            .enumerate()
864            .filter(|(index, _)| *index != column_index)
865            .map(|(_, column)| Arc::clone(column))
866            .collect();
867        let new_schema = Schema::new_with_metadata(fields, schema.metadata().clone());
868
869        RecordBatch::try_new(Arc::new(new_schema), columns).unwrap()
870    }
871
872    fn batch_with_null_string_column(record_batch: &RecordBatch, column_name: &str) -> RecordBatch {
873        let schema = record_batch.schema();
874        let column_index = schema.index_of(column_name).unwrap();
875        let mut columns = record_batch.columns().to_vec();
876        let null_column: ArrayRef = Arc::new(StringArray::from(vec![None::<&str>]));
877        columns[column_index] = null_column;
878
879        RecordBatch::try_new(schema, columns).unwrap()
880    }
881
882    fn batch_with_string_column(
883        record_batch: &RecordBatch,
884        column_name: &str,
885        value: &str,
886    ) -> RecordBatch {
887        let schema = record_batch.schema();
888        let column_index = schema.index_of(column_name).unwrap();
889        let mut columns = record_batch.columns().to_vec();
890        columns[column_index] = Arc::new(StringArray::from(vec![value]));
891
892        RecordBatch::try_new(schema, columns).unwrap()
893    }
894
895    fn batch_with_uint8_column(
896        record_batch: &RecordBatch,
897        column_name: &str,
898        values: Vec<u8>,
899    ) -> RecordBatch {
900        let schema = record_batch.schema();
901        let column_index = schema.index_of(column_name).unwrap();
902        let mut columns = record_batch.columns().to_vec();
903        columns[column_index] = Arc::new(UInt8Array::from(values));
904
905        RecordBatch::try_new(schema, columns).unwrap()
906    }
907
908    #[rstest]
909    #[case::binary_option(InstrumentAny::BinaryOption(
910        nautilus_model::instruments::stubs::binary_option()
911    ))]
912    #[case::cfd(InstrumentAny::Cfd(nautilus_model::instruments::stubs::cfd_gold()))]
913    #[case::commodity(InstrumentAny::Commodity(
914        nautilus_model::instruments::stubs::commodity_gold()
915    ))]
916    #[case::crypto_future(InstrumentAny::CryptoFuture(
917        nautilus_model::instruments::stubs::crypto_future_btcusdt(
918            2,
919            6,
920            Price::from("0.01"),
921            Quantity::from("0.000001"),
922        )
923    ))]
924    #[case::crypto_futures_spread(InstrumentAny::CryptoFuturesSpread(
925        nautilus_model::instruments::stubs::crypto_futures_spread_btc_deribit()
926    ))]
927    #[case::crypto_option(InstrumentAny::CryptoOption(
928        nautilus_model::instruments::stubs::crypto_option_btc_deribit(
929            3,
930            1,
931            Price::from("0.001"),
932            Quantity::from("0.1"),
933        )
934    ))]
935    #[case::crypto_option_spread(InstrumentAny::CryptoOptionSpread(
936        nautilus_model::instruments::stubs::crypto_option_spread_btc_deribit()
937    ))]
938    #[case::crypto_perpetual(InstrumentAny::CryptoPerpetual(
939        nautilus_model::instruments::stubs::crypto_perpetual_ethusdt()
940    ))]
941    #[case::currency_pair(InstrumentAny::CurrencyPair(
942        nautilus_model::instruments::stubs::currency_pair_btcusdt()
943    ))]
944    #[case::equity(InstrumentAny::Equity(nautilus_model::instruments::stubs::equity_aapl()))]
945    #[case::futures_contract(InstrumentAny::FuturesContract(
946        nautilus_model::instruments::stubs::futures_contract_es(None, None,)
947    ))]
948    #[case::futures_spread(InstrumentAny::FuturesSpread(
949        nautilus_model::instruments::stubs::futures_spread_es()
950    ))]
951    #[case::index_instrument(InstrumentAny::IndexInstrument(
952        nautilus_model::instruments::stubs::index_instrument_spx()
953    ))]
954    #[case::option_contract(InstrumentAny::OptionContract(
955        nautilus_model::instruments::stubs::option_contract_appl()
956    ))]
957    #[case::option_spread(InstrumentAny::OptionSpread(
958        nautilus_model::instruments::stubs::option_spread()
959    ))]
960    #[case::perpetual_contract(InstrumentAny::PerpetualContract(
961        nautilus_model::instruments::stubs::perpetual_contract_eurusd()
962    ))]
963    #[case::tokenized_asset(InstrumentAny::TokenizedAsset(
964        nautilus_model::instruments::stubs::tokenized_asset_aaplx()
965    ))]
966    fn test_decode_instrument_checked_constructor_error(#[case] instrument: InstrumentAny) {
967        let metadata = instrument.metadata();
968        let class = metadata.get(KEY_CLASS).unwrap();
969        let first_row_price_precision = Instrument::price_precision(&instrument);
970        let instruments = vec![instrument.clone(), instrument];
971        let record_batch = InstrumentAny::encode_batch(&metadata, &instruments).unwrap();
972        let record_batch = batch_with_uint8_column(
973            &record_batch,
974            "price_precision",
975            vec![first_row_price_precision, u8::MAX],
976        );
977
978        let error = decode_instrument_any_batch(&metadata, &record_batch)
979            .expect_err("invalid precision must return EncodingError");
980
981        match error {
982            EncodingError::ParseError(field, message) => {
983                assert_eq!(field, INSTRUMENT_VALIDATION_FIELD);
984                assert!(
985                    message.contains(class),
986                    "message should include instrument class, found: {message}",
987                );
988                assert!(
989                    message.starts_with("row 1:"),
990                    "message should include row index, found: {message}",
991                );
992                assert!(
993                    message.contains("price_precision"),
994                    "message should include failed precision, found: {message}",
995                );
996            }
997            other => panic!("unexpected error variant: {other:?}"),
998        }
999    }
1000
1001    #[rstest]
1002    fn test_roundtrip_betting() {
1003        use nautilus_model::instruments::stubs::betting;
1004        roundtrip_case(&InstrumentAny::Betting(betting()));
1005    }
1006
1007    #[rstest]
1008    fn test_roundtrip_binary_option() {
1009        use nautilus_model::instruments::stubs::binary_option;
1010        roundtrip_case(&InstrumentAny::BinaryOption(binary_option()));
1011    }
1012
1013    #[rstest]
1014    fn test_roundtrip_cfd() {
1015        use nautilus_model::instruments::stubs::cfd_gold;
1016        roundtrip_case(&InstrumentAny::Cfd(cfd_gold()));
1017    }
1018
1019    #[rstest]
1020    fn test_roundtrip_commodity() {
1021        use nautilus_model::instruments::stubs::commodity_gold;
1022        roundtrip_case(&InstrumentAny::Commodity(commodity_gold()));
1023    }
1024
1025    #[rstest]
1026    fn test_roundtrip_crypto_future() {
1027        use nautilus_model::instruments::stubs::crypto_future_btcusdt;
1028
1029        let mut inst = crypto_future_btcusdt(2, 6, Price::from("0.01"), Quantity::from("0.000001"));
1030        inst.lot_size = Quantity::from("0.25");
1031        let any = InstrumentAny::CryptoFuture(inst.clone());
1032        roundtrip_case(&any);
1033        let metadata = any.metadata();
1034        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1035        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1036        let InstrumentAny::CryptoFuture(decoded_inst) = &decoded[0] else {
1037            panic!("decoded variant is not CryptoFuture");
1038        };
1039        assert_eq!(decoded_inst.lot_size, inst.lot_size);
1040    }
1041
1042    #[rstest]
1043    fn test_decode_crypto_future_without_lot_size_column_defaults_to_one() {
1044        use nautilus_model::instruments::stubs::crypto_future_btcusdt;
1045
1046        let inst = crypto_future_btcusdt(2, 6, Price::from("0.01"), Quantity::from("0.000001"));
1047        let any = InstrumentAny::CryptoFuture(inst);
1048        let metadata = any.metadata();
1049        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1050        let batch = batch_without_column(&batch, "lot_size");
1051
1052        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1053
1054        let InstrumentAny::CryptoFuture(decoded_inst) = &decoded[0] else {
1055            panic!("decoded variant is not CryptoFuture");
1056        };
1057        assert_eq!(decoded_inst.lot_size, Quantity::from(1));
1058    }
1059
1060    #[rstest]
1061    fn test_decode_crypto_future_null_lot_size_defaults_to_one() {
1062        use nautilus_model::instruments::stubs::crypto_future_btcusdt;
1063
1064        let inst = crypto_future_btcusdt(2, 6, Price::from("0.01"), Quantity::from("0.000001"));
1065        let any = InstrumentAny::CryptoFuture(inst);
1066        let metadata = any.metadata();
1067        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1068        let batch = batch_with_null_string_column(&batch, "lot_size");
1069
1070        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1071
1072        let InstrumentAny::CryptoFuture(decoded_inst) = &decoded[0] else {
1073            panic!("decoded variant is not CryptoFuture");
1074        };
1075        assert_eq!(decoded_inst.lot_size, Quantity::from(1));
1076    }
1077
1078    #[rstest]
1079    fn test_roundtrip_crypto_option() {
1080        use nautilus_model::instruments::stubs::crypto_option_btc_deribit;
1081
1082        let mut inst = crypto_option_btc_deribit(3, 1, Price::from("0.001"), Quantity::from("0.1"));
1083        inst.lot_size = Quantity::from("0.5");
1084        let any = InstrumentAny::CryptoOption(inst.clone());
1085        roundtrip_case(&any);
1086        let metadata = any.metadata();
1087        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1088        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1089        let InstrumentAny::CryptoOption(decoded_inst) = &decoded[0] else {
1090            panic!("decoded variant is not CryptoOption");
1091        };
1092        assert_eq!(decoded_inst.lot_size, inst.lot_size);
1093    }
1094
1095    #[rstest]
1096    fn test_decode_crypto_option_without_lot_size_column_defaults_to_one() {
1097        use nautilus_model::instruments::stubs::crypto_option_btc_deribit;
1098
1099        let inst = crypto_option_btc_deribit(3, 1, Price::from("0.001"), Quantity::from("0.1"));
1100        let any = InstrumentAny::CryptoOption(inst);
1101        let metadata = any.metadata();
1102        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1103        let batch = batch_without_column(&batch, "lot_size");
1104
1105        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1106
1107        let InstrumentAny::CryptoOption(decoded_inst) = &decoded[0] else {
1108            panic!("decoded variant is not CryptoOption");
1109        };
1110        assert_eq!(decoded_inst.lot_size, Quantity::from(1));
1111    }
1112
1113    #[rstest]
1114    fn test_decode_crypto_option_null_lot_size_defaults_to_one() {
1115        use nautilus_model::instruments::stubs::crypto_option_btc_deribit;
1116
1117        let inst = crypto_option_btc_deribit(3, 1, Price::from("0.001"), Quantity::from("0.1"));
1118        let any = InstrumentAny::CryptoOption(inst);
1119        let metadata = any.metadata();
1120        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1121        let batch = batch_with_null_string_column(&batch, "lot_size");
1122
1123        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1124
1125        let InstrumentAny::CryptoOption(decoded_inst) = &decoded[0] else {
1126            panic!("decoded variant is not CryptoOption");
1127        };
1128        assert_eq!(decoded_inst.lot_size, Quantity::from(1));
1129    }
1130
1131    #[rstest]
1132    fn test_roundtrip_crypto_futures_spread() {
1133        use nautilus_model::instruments::{Instrument, stubs::crypto_futures_spread_btc_deribit};
1134        let inst = crypto_futures_spread_btc_deribit();
1135        let any = InstrumentAny::CryptoFuturesSpread(inst.clone());
1136        roundtrip_case(&any);
1137        let metadata = any.metadata();
1138        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1139        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1140        let InstrumentAny::CryptoFuturesSpread(decoded_inst) = &decoded[0] else {
1141            panic!("decoded variant is not CryptoFuturesSpread");
1142        };
1143        assert_eq!(decoded_inst.lot_size, inst.lot_size);
1144        assert_eq!(decoded_inst.is_inverse, inst.is_inverse);
1145        assert_eq!(decoded_inst.strategy_type, inst.strategy_type);
1146        assert_eq!(decoded_inst.settlement_currency, inst.settlement_currency);
1147        assert_eq!(Instrument::id(decoded_inst), Instrument::id(&inst));
1148    }
1149
1150    #[rstest]
1151    fn test_roundtrip_crypto_option_spread() {
1152        use nautilus_model::instruments::{Instrument, stubs::crypto_option_spread_btc_deribit};
1153        let inst = crypto_option_spread_btc_deribit();
1154        let any = InstrumentAny::CryptoOptionSpread(inst.clone());
1155        roundtrip_case(&any);
1156        let metadata = any.metadata();
1157        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1158        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1159        let InstrumentAny::CryptoOptionSpread(decoded_inst) = &decoded[0] else {
1160            panic!("decoded variant is not CryptoOptionSpread");
1161        };
1162        // Deribit BTC option combos carry min_trade_amount=0.1, which sets
1163        // lot_size=0.1; dropping the lot_size Arrow column would silently
1164        // default it back to 1
1165        assert_eq!(decoded_inst.lot_size, inst.lot_size);
1166        assert_eq!(decoded_inst.size_precision, inst.size_precision);
1167        assert_eq!(decoded_inst.size_increment, inst.size_increment);
1168        assert_eq!(decoded_inst.is_inverse, inst.is_inverse);
1169        assert_eq!(decoded_inst.strategy_type, inst.strategy_type);
1170        assert_eq!(decoded_inst.settlement_currency, inst.settlement_currency);
1171        assert_eq!(Instrument::id(decoded_inst), Instrument::id(&inst));
1172    }
1173
1174    #[rstest]
1175    fn test_roundtrip_crypto_perpetual_inverse() {
1176        use nautilus_model::instruments::stubs::xbtusd_bitmex;
1177        roundtrip_case(&InstrumentAny::CryptoPerpetual(xbtusd_bitmex()));
1178    }
1179
1180    #[rstest]
1181    fn test_roundtrip_crypto_perpetual_linear() {
1182        use nautilus_model::instruments::stubs::crypto_perpetual_ethusdt;
1183
1184        let mut inst = crypto_perpetual_ethusdt();
1185        inst.lot_size = Quantity::from("0.005");
1186        let any = InstrumentAny::CryptoPerpetual(inst.clone());
1187        roundtrip_case(&any);
1188        let metadata = any.metadata();
1189        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1190        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1191        let InstrumentAny::CryptoPerpetual(decoded_inst) = &decoded[0] else {
1192            panic!("decoded variant is not CryptoPerpetual");
1193        };
1194        assert_eq!(decoded_inst.lot_size, inst.lot_size);
1195    }
1196
1197    #[rstest]
1198    fn test_decode_crypto_perpetual_without_lot_size_column_defaults_to_one() {
1199        use nautilus_model::instruments::stubs::crypto_perpetual_ethusdt;
1200
1201        let inst = crypto_perpetual_ethusdt();
1202        let any = InstrumentAny::CryptoPerpetual(inst);
1203        let metadata = any.metadata();
1204        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1205        let batch = batch_without_column(&batch, "lot_size");
1206
1207        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1208
1209        let InstrumentAny::CryptoPerpetual(decoded_inst) = &decoded[0] else {
1210            panic!("decoded variant is not CryptoPerpetual");
1211        };
1212        assert_eq!(decoded_inst.lot_size, Quantity::from(1));
1213    }
1214
1215    #[rstest]
1216    fn test_decode_crypto_perpetual_null_lot_size_defaults_to_one() {
1217        use nautilus_model::instruments::stubs::crypto_perpetual_ethusdt;
1218
1219        let inst = crypto_perpetual_ethusdt();
1220        let any = InstrumentAny::CryptoPerpetual(inst);
1221        let metadata = any.metadata();
1222        let batch = InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&any)).unwrap();
1223        let batch = batch_with_null_string_column(&batch, "lot_size");
1224
1225        let decoded = decode_instrument_any_batch(&metadata, &batch).unwrap();
1226
1227        let InstrumentAny::CryptoPerpetual(decoded_inst) = &decoded[0] else {
1228            panic!("decoded variant is not CryptoPerpetual");
1229        };
1230        assert_eq!(decoded_inst.lot_size, Quantity::from(1));
1231    }
1232
1233    #[rstest]
1234    fn test_roundtrip_futures_contract() {
1235        use nautilus_model::instruments::stubs::futures_contract_es;
1236        roundtrip_case(&InstrumentAny::FuturesContract(futures_contract_es(
1237            None, None,
1238        )));
1239    }
1240
1241    #[rstest]
1242    fn test_roundtrip_futures_spread() {
1243        use nautilus_model::instruments::stubs::futures_spread_es;
1244        roundtrip_case(&InstrumentAny::FuturesSpread(futures_spread_es()));
1245    }
1246
1247    #[rstest]
1248    fn test_roundtrip_index_instrument() {
1249        use nautilus_model::instruments::stubs::index_instrument_spx;
1250        roundtrip_case(&InstrumentAny::IndexInstrument(index_instrument_spx()));
1251    }
1252
1253    #[rstest]
1254    fn test_roundtrip_option_contract() {
1255        use nautilus_model::instruments::stubs::option_contract_appl;
1256        roundtrip_case(&InstrumentAny::OptionContract(option_contract_appl()));
1257    }
1258
1259    #[rstest]
1260    fn test_roundtrip_option_spread() {
1261        use nautilus_model::instruments::stubs::option_spread;
1262        roundtrip_case(&InstrumentAny::OptionSpread(option_spread()));
1263    }
1264
1265    #[rstest]
1266    fn test_roundtrip_perpetual_contract() {
1267        use nautilus_model::instruments::stubs::perpetual_contract_eurusd;
1268        roundtrip_case(&InstrumentAny::PerpetualContract(
1269            perpetual_contract_eurusd(),
1270        ));
1271    }
1272
1273    #[rstest]
1274    fn test_roundtrip_tokenized_asset() {
1275        use nautilus_model::instruments::stubs::tokenized_asset_aaplx;
1276        roundtrip_case(&InstrumentAny::TokenizedAsset(tokenized_asset_aaplx()));
1277    }
1278
1279    fn encoded_string_column(instrument: &InstrumentAny, name: &str) -> Option<String> {
1280        let metadata = instrument.metadata();
1281        let batch =
1282            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(instrument)).unwrap();
1283        batch.column_by_name(name).map(|column| {
1284            column
1285                .as_any()
1286                .downcast_ref::<StringArray>()
1287                .unwrap_or_else(|| panic!("{name} column is not Utf8"))
1288                .value(0)
1289                .to_string()
1290        })
1291    }
1292
1293    #[rstest]
1294    #[case::binary_option(InstrumentAny::BinaryOption(
1295        nautilus_model::instruments::stubs::binary_option()
1296    ))]
1297    #[case::cfd(InstrumentAny::Cfd(nautilus_model::instruments::stubs::cfd_gold()))]
1298    #[case::commodity(InstrumentAny::Commodity(
1299        nautilus_model::instruments::stubs::commodity_gold()
1300    ))]
1301    #[case::futures_contract(InstrumentAny::FuturesContract(
1302        nautilus_model::instruments::stubs::futures_contract_es(None, None)
1303    ))]
1304    #[case::futures_spread(InstrumentAny::FuturesSpread(
1305        nautilus_model::instruments::stubs::futures_spread_es()
1306    ))]
1307    #[case::option_contract(InstrumentAny::OptionContract(
1308        nautilus_model::instruments::stubs::option_contract_appl()
1309    ))]
1310    #[case::option_spread(InstrumentAny::OptionSpread(
1311        nautilus_model::instruments::stubs::option_spread()
1312    ))]
1313    #[case::perpetual_contract(InstrumentAny::PerpetualContract(
1314        nautilus_model::instruments::stubs::perpetual_contract_eurusd()
1315    ))]
1316    #[case::tokenized_asset(InstrumentAny::TokenizedAsset(
1317        nautilus_model::instruments::stubs::tokenized_asset_aaplx()
1318    ))]
1319    fn test_encoded_asset_class_uses_canonical_label(#[case] instrument: InstrumentAny) {
1320        assert_eq!(
1321            encoded_string_column(&instrument, "asset_class"),
1322            Some(Instrument::asset_class(&instrument).as_ref().to_string()),
1323        );
1324    }
1325
1326    #[rstest]
1327    #[case::crypto_option(InstrumentAny::CryptoOption(
1328        nautilus_model::instruments::stubs::crypto_option_btc_deribit(
1329            3,
1330            1,
1331            Price::from("0.001"),
1332            Quantity::from("0.1"),
1333        )
1334    ))]
1335    #[case::option_contract(InstrumentAny::OptionContract(
1336        nautilus_model::instruments::stubs::option_contract_appl()
1337    ))]
1338    fn test_encoded_option_kind_uses_canonical_label(#[case] instrument: InstrumentAny) {
1339        let expected =
1340            Instrument::option_kind(&instrument).expect("stub must carry an option kind");
1341        assert_eq!(
1342            encoded_string_column(&instrument, "option_kind"),
1343            Some(expected.as_ref().to_string()),
1344        );
1345    }
1346
1347    fn decoded_option_contract_with_column(name: &str, value: &str) -> InstrumentAny {
1348        let instrument = InstrumentAny::OptionContract(
1349            nautilus_model::instruments::stubs::option_contract_appl(),
1350        );
1351        let metadata = instrument.metadata();
1352        let batch =
1353            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
1354        let batch = batch_with_string_column(&batch, name, value);
1355
1356        decode_instrument_any_batch(&metadata, &batch)
1357            .unwrap()
1358            .remove(0)
1359    }
1360
1361    #[rstest]
1362    #[case("EQUITY", AssetClass::Equity)]
1363    #[case("Equity", AssetClass::Equity)]
1364    #[case("CRYPTOCURRENCY", AssetClass::Cryptocurrency)]
1365    #[case("Cryptocurrency", AssetClass::Cryptocurrency)]
1366    #[case("FX", AssetClass::FX)]
1367    fn test_decode_asset_class_accepts_legacy_labels(
1368        #[case] label: &str,
1369        #[case] expected: AssetClass,
1370    ) {
1371        let decoded = decoded_option_contract_with_column("asset_class", label);
1372
1373        assert_eq!(Instrument::asset_class(&decoded), expected);
1374    }
1375
1376    #[rstest]
1377    #[case("CALL", OptionKind::Call)]
1378    #[case("Call", OptionKind::Call)]
1379    #[case("PUT", OptionKind::Put)]
1380    #[case("Put", OptionKind::Put)]
1381    fn test_decode_option_kind_accepts_legacy_labels(
1382        #[case] label: &str,
1383        #[case] expected: OptionKind,
1384    ) {
1385        let decoded = decoded_option_contract_with_column("option_kind", label);
1386
1387        assert_eq!(Instrument::option_kind(&decoded), Some(expected));
1388    }
1389}