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