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::collections::HashMap;
23
24use arrow::{datatypes::Schema, error::ArrowError, record_batch::RecordBatch};
25use nautilus_model::{
26    instruments::{
27        Instrument, InstrumentAny, betting::BettingInstrument, binary_option::BinaryOption,
28        cfd::Cfd, commodity::Commodity, crypto_future::CryptoFuture, crypto_option::CryptoOption,
29        crypto_perpetual::CryptoPerpetual, currency_pair::CurrencyPair, equity::Equity,
30        futures_contract::FuturesContract, futures_spread::FuturesSpread,
31        index_instrument::IndexInstrument, option_contract::OptionContract,
32        option_spread::OptionSpread, perpetual_contract::PerpetualContract,
33        tokenized_asset::TokenizedAsset,
34    },
35    types::Currency,
36};
37
38#[allow(unused)]
39use crate::arrow::{
40    ArrowSchemaProvider, Data, DecodeDataFromRecordBatch, DecodeFromRecordBatch,
41    EncodeToRecordBatch, EncodingError, KEY_INSTRUMENT_ID,
42};
43
44pub mod betting;
45pub mod binary_option;
46pub mod cfd;
47pub mod commodity;
48pub mod crypto_future;
49pub mod crypto_option;
50pub mod crypto_perpetual;
51pub mod currency_pair;
52pub mod equity;
53pub mod futures_contract;
54pub mod futures_spread;
55pub mod index_instrument;
56pub mod option_contract;
57pub mod option_spread;
58pub mod perpetual_contract;
59pub mod tokenized_asset;
60
61// Errors on empty/whitespace codes so corrupted rows surface as ParseError,
62// instead of silently registering as a fallback currency. Known codes resolve
63// from CURRENCY_MAP with original metadata; unknown non-empty codes fall back
64// to a new crypto currency to support newly listed exchange assets.
65pub(crate) fn decode_currency(
66    value: &str,
67    field: &'static str,
68    context: &'static str,
69    row: usize,
70) -> Result<Currency, EncodingError> {
71    let trimmed = value.trim();
72    if trimmed.is_empty() {
73        return Err(EncodingError::ParseError(
74            field,
75            format!("row {row}: empty currency code"),
76        ));
77    }
78
79    Ok(Currency::get_or_create_crypto_with_context(
80        trimmed,
81        Some(context),
82    ))
83}
84
85impl ArrowSchemaProvider for InstrumentAny {
86    fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema {
87        let instrument_type = metadata
88            .as_ref()
89            .and_then(|m| m.get("class"))
90            .map_or("CurrencyPair", |s| s.as_str());
91
92        match instrument_type {
93            "BettingInstrument" => BettingInstrument::get_schema(metadata),
94            "BinaryOption" => BinaryOption::get_schema(metadata),
95            "Cfd" => Cfd::get_schema(metadata),
96            "Commodity" => Commodity::get_schema(metadata),
97            "CryptoFuture" => CryptoFuture::get_schema(metadata),
98            "CryptoOption" => CryptoOption::get_schema(metadata),
99            "CryptoPerpetual" => CryptoPerpetual::get_schema(metadata),
100            "CurrencyPair" => CurrencyPair::get_schema(metadata),
101            "Equity" => Equity::get_schema(metadata),
102            "FuturesContract" => FuturesContract::get_schema(metadata),
103            "FuturesSpread" => FuturesSpread::get_schema(metadata),
104            "IndexInstrument" => IndexInstrument::get_schema(metadata),
105            "OptionContract" => OptionContract::get_schema(metadata),
106            "OptionSpread" => OptionSpread::get_schema(metadata),
107            "PerpetualContract" => PerpetualContract::get_schema(metadata),
108            "TokenizedAsset" => TokenizedAsset::get_schema(metadata),
109            _ => {
110                // Fallback to CurrencyPair schema if type is unknown
111                CurrencyPair::get_schema(metadata)
112            }
113        }
114    }
115}
116
117impl EncodeToRecordBatch for InstrumentAny {
118    fn encode_batch(
119        #[allow(unused)] metadata: &HashMap<String, String>,
120        data: &[Self],
121    ) -> Result<RecordBatch, ArrowError> {
122        if data.is_empty() {
123            return Err(ArrowError::InvalidArgumentError(
124                "Cannot encode empty instrument batch".to_string(),
125            ));
126        }
127
128        let mut by_type: HashMap<String, Vec<&Self>> = HashMap::new();
129
130        for instrument in data {
131            let type_name = match instrument {
132                Self::Cfd(_) => "Cfd",
133                Self::Commodity(_) => "Commodity",
134                Self::CurrencyPair(_) => "CurrencyPair",
135                Self::Equity(_) => "Equity",
136                Self::CryptoFuture(_) => "CryptoFuture",
137                Self::CryptoPerpetual(_) => "CryptoPerpetual",
138                Self::CryptoOption(_) => "CryptoOption",
139                Self::FuturesContract(_) => "FuturesContract",
140                Self::FuturesSpread(_) => "FuturesSpread",
141                Self::IndexInstrument(_) => "IndexInstrument",
142                Self::OptionContract(_) => "OptionContract",
143                Self::OptionSpread(_) => "OptionSpread",
144                Self::BinaryOption(_) => "BinaryOption",
145                Self::Betting(_) => "BettingInstrument",
146                Self::PerpetualContract(_) => "PerpetualContract",
147                Self::TokenizedAsset(_) => "TokenizedAsset",
148            };
149            by_type
150                .entry(type_name.to_string())
151                .or_default()
152                .push(instrument);
153        }
154
155        if by_type.len() > 1 {
156            return Err(ArrowError::InvalidArgumentError(
157                "Cannot encode mixed instrument types in a single batch. Use separate batches for each type.".to_string(),
158            ));
159        }
160
161        let (type_name, instruments) = by_type.iter().next().unwrap();
162        match type_name.as_str() {
163            "Cfd" => {
164                let cfds: Vec<_> = instruments
165                    .iter()
166                    .map(|i| {
167                        if let Self::Cfd(c) = i {
168                            c
169                        } else {
170                            unreachable!()
171                        }
172                    })
173                    .cloned()
174                    .collect();
175                Cfd::encode_batch(metadata, &cfds)
176            }
177            "Commodity" => {
178                let commodities: Vec<_> = instruments
179                    .iter()
180                    .map(|i| {
181                        if let Self::Commodity(c) = i {
182                            c
183                        } else {
184                            unreachable!()
185                        }
186                    })
187                    .cloned()
188                    .collect();
189                Commodity::encode_batch(metadata, &commodities)
190            }
191            "BettingInstrument" => {
192                let betting: Vec<_> = instruments
193                    .iter()
194                    .map(|i| {
195                        if let Self::Betting(b) = i {
196                            b
197                        } else {
198                            unreachable!()
199                        }
200                    })
201                    .cloned()
202                    .collect();
203                BettingInstrument::encode_batch(metadata, &betting)
204            }
205            "BinaryOption" => {
206                let binary_options: Vec<_> = instruments
207                    .iter()
208                    .map(|i| {
209                        if let Self::BinaryOption(bo) = i {
210                            bo
211                        } else {
212                            unreachable!()
213                        }
214                    })
215                    .cloned()
216                    .collect();
217                BinaryOption::encode_batch(metadata, &binary_options)
218            }
219            "CryptoFuture" => {
220                let crypto_futures: Vec<_> = instruments
221                    .iter()
222                    .map(|i| {
223                        if let Self::CryptoFuture(cf) = i {
224                            cf
225                        } else {
226                            unreachable!()
227                        }
228                    })
229                    .cloned()
230                    .collect();
231                CryptoFuture::encode_batch(metadata, &crypto_futures)
232            }
233            "CryptoOption" => {
234                let crypto_options: Vec<_> = instruments
235                    .iter()
236                    .map(|i| {
237                        if let Self::CryptoOption(co) = i {
238                            co
239                        } else {
240                            unreachable!()
241                        }
242                    })
243                    .cloned()
244                    .collect();
245                CryptoOption::encode_batch(metadata, &crypto_options)
246            }
247            "CryptoPerpetual" => {
248                let crypto_perps: Vec<_> = instruments
249                    .iter()
250                    .map(|i| {
251                        if let Self::CryptoPerpetual(cp) = i {
252                            cp
253                        } else {
254                            unreachable!()
255                        }
256                    })
257                    .cloned()
258                    .collect();
259                CryptoPerpetual::encode_batch(metadata, &crypto_perps)
260            }
261            "CurrencyPair" => {
262                let currency_pairs: Vec<_> = instruments
263                    .iter()
264                    .map(|i| {
265                        if let Self::CurrencyPair(cp) = i {
266                            cp
267                        } else {
268                            unreachable!()
269                        }
270                    })
271                    .cloned()
272                    .collect();
273                CurrencyPair::encode_batch(metadata, &currency_pairs)
274            }
275            "Equity" => {
276                let equities: Vec<_> = instruments
277                    .iter()
278                    .map(|i| {
279                        if let Self::Equity(e) = i {
280                            e
281                        } else {
282                            unreachable!()
283                        }
284                    })
285                    .cloned()
286                    .collect();
287                Equity::encode_batch(metadata, &equities)
288            }
289            "FuturesContract" => {
290                let futures_contracts: Vec<_> = instruments
291                    .iter()
292                    .map(|i| {
293                        if let Self::FuturesContract(fc) = i {
294                            fc
295                        } else {
296                            unreachable!()
297                        }
298                    })
299                    .cloned()
300                    .collect();
301                FuturesContract::encode_batch(metadata, &futures_contracts)
302            }
303            "FuturesSpread" => {
304                let futures_spreads: Vec<_> = instruments
305                    .iter()
306                    .map(|i| {
307                        if let Self::FuturesSpread(fs) = i {
308                            fs
309                        } else {
310                            unreachable!()
311                        }
312                    })
313                    .cloned()
314                    .collect();
315                FuturesSpread::encode_batch(metadata, &futures_spreads)
316            }
317            "IndexInstrument" => {
318                let index_instruments: Vec<_> = instruments
319                    .iter()
320                    .map(|i| {
321                        if let Self::IndexInstrument(ii) = i {
322                            ii
323                        } else {
324                            unreachable!()
325                        }
326                    })
327                    .cloned()
328                    .collect();
329                IndexInstrument::encode_batch(metadata, &index_instruments)
330            }
331            "OptionContract" => {
332                let option_contracts: Vec<_> = instruments
333                    .iter()
334                    .map(|i| {
335                        if let Self::OptionContract(oc) = i {
336                            oc
337                        } else {
338                            unreachable!()
339                        }
340                    })
341                    .cloned()
342                    .collect();
343                OptionContract::encode_batch(metadata, &option_contracts)
344            }
345            "OptionSpread" => {
346                let option_spreads: Vec<_> = instruments
347                    .iter()
348                    .map(|i| {
349                        if let Self::OptionSpread(os) = i {
350                            os
351                        } else {
352                            unreachable!()
353                        }
354                    })
355                    .cloned()
356                    .collect();
357                OptionSpread::encode_batch(metadata, &option_spreads)
358            }
359            "PerpetualContract" => {
360                let perpetual_contracts: Vec<_> = instruments
361                    .iter()
362                    .map(|i| {
363                        if let Self::PerpetualContract(pc) = i {
364                            pc
365                        } else {
366                            unreachable!()
367                        }
368                    })
369                    .cloned()
370                    .collect();
371                PerpetualContract::encode_batch(metadata, &perpetual_contracts)
372            }
373            "TokenizedAsset" => {
374                let tokenized_assets: Vec<_> = instruments
375                    .iter()
376                    .map(|i| {
377                        if let Self::TokenizedAsset(ta) = i {
378                            ta
379                        } else {
380                            unreachable!()
381                        }
382                    })
383                    .cloned()
384                    .collect();
385                TokenizedAsset::encode_batch(metadata, &tokenized_assets)
386            }
387            _ => Err(ArrowError::InvalidArgumentError(format!(
388                "Instrument type {type_name} serialization not yet implemented"
389            ))),
390        }
391    }
392
393    fn metadata(&self) -> HashMap<String, String> {
394        let mut metadata = HashMap::new();
395        metadata.insert(
396            KEY_INSTRUMENT_ID.to_string(),
397            Instrument::id(self).to_string(),
398        );
399
400        let type_name = match self {
401            Self::Cfd(_) => "Cfd",
402            Self::Commodity(_) => "Commodity",
403            Self::CurrencyPair(_) => "CurrencyPair",
404            Self::Equity(_) => "Equity",
405            Self::CryptoFuture(_) => "CryptoFuture",
406            Self::CryptoPerpetual(_) => "CryptoPerpetual",
407            Self::CryptoOption(_) => "CryptoOption",
408            Self::FuturesContract(_) => "FuturesContract",
409            Self::FuturesSpread(_) => "FuturesSpread",
410            Self::IndexInstrument(_) => "IndexInstrument",
411            Self::OptionContract(_) => "OptionContract",
412            Self::OptionSpread(_) => "OptionSpread",
413            Self::BinaryOption(_) => "BinaryOption",
414            Self::Betting(_) => "BettingInstrument",
415            Self::PerpetualContract(_) => "PerpetualContract",
416            Self::TokenizedAsset(_) => "TokenizedAsset",
417        };
418        metadata.insert("class".to_string(), type_name.to_string());
419        metadata
420    }
421}
422
423/// Decode InstrumentAny from RecordBatch
424/// (Cannot implement DecodeFromRecordBatch trait due to `Into<Data>` bound)
425///
426/// # Errors
427///
428/// Returns an `EncodingError` if the RecordBatch cannot be decoded.
429pub fn decode_instrument_any_batch(
430    #[allow(unused)] metadata: &HashMap<String, String>,
431    record_batch: &RecordBatch,
432) -> Result<Vec<InstrumentAny>, EncodingError> {
433    let type_name = metadata
434        .get("class")
435        .map(String::as_str)
436        .ok_or_else(|| EncodingError::MissingMetadata("class"))?;
437
438    match type_name {
439        "Cfd" => {
440            let cfds = cfd::decode_cfd_batch(metadata, record_batch)?;
441            Ok(cfds.into_iter().map(InstrumentAny::Cfd).collect())
442        }
443        "Commodity" => {
444            let commodities = commodity::decode_commodity_batch(metadata, record_batch)?;
445            Ok(commodities
446                .into_iter()
447                .map(InstrumentAny::Commodity)
448                .collect())
449        }
450        "BettingInstrument" => {
451            let betting = betting::decode_betting_instrument_batch(metadata, record_batch)?;
452            Ok(betting.into_iter().map(InstrumentAny::Betting).collect())
453        }
454        "BinaryOption" => {
455            let binary_options = binary_option::decode_binary_option_batch(metadata, record_batch)?;
456            Ok(binary_options
457                .into_iter()
458                .map(InstrumentAny::BinaryOption)
459                .collect())
460        }
461        "CryptoFuture" => {
462            let crypto_futures = crypto_future::decode_crypto_future_batch(metadata, record_batch)?;
463            Ok(crypto_futures
464                .into_iter()
465                .map(InstrumentAny::CryptoFuture)
466                .collect())
467        }
468        "CryptoOption" => {
469            let crypto_options = crypto_option::decode_crypto_option_batch(metadata, record_batch)?;
470            Ok(crypto_options
471                .into_iter()
472                .map(InstrumentAny::CryptoOption)
473                .collect())
474        }
475        "CryptoPerpetual" => {
476            let crypto_perps =
477                crypto_perpetual::decode_crypto_perpetual_batch(metadata, record_batch)?;
478            Ok(crypto_perps
479                .into_iter()
480                .map(InstrumentAny::CryptoPerpetual)
481                .collect())
482        }
483        "CurrencyPair" => {
484            let currency_pairs = currency_pair::decode_currency_pair_batch(metadata, record_batch)?;
485            Ok(currency_pairs
486                .into_iter()
487                .map(InstrumentAny::CurrencyPair)
488                .collect())
489        }
490        "Equity" => {
491            let equities = equity::decode_equity_batch(metadata, record_batch)?;
492            Ok(equities.into_iter().map(InstrumentAny::Equity).collect())
493        }
494        "FuturesContract" => {
495            let futures_contracts =
496                futures_contract::decode_futures_contract_batch(metadata, record_batch)?;
497            Ok(futures_contracts
498                .into_iter()
499                .map(InstrumentAny::FuturesContract)
500                .collect())
501        }
502        "FuturesSpread" => {
503            let futures_spreads =
504                futures_spread::decode_futures_spread_batch(metadata, record_batch)?;
505            Ok(futures_spreads
506                .into_iter()
507                .map(InstrumentAny::FuturesSpread)
508                .collect())
509        }
510        "IndexInstrument" => {
511            let index_instruments =
512                index_instrument::decode_index_instrument_batch(metadata, record_batch)?;
513            Ok(index_instruments
514                .into_iter()
515                .map(InstrumentAny::IndexInstrument)
516                .collect())
517        }
518        "OptionContract" => {
519            let option_contracts =
520                option_contract::decode_option_contract_batch(metadata, record_batch)?;
521            Ok(option_contracts
522                .into_iter()
523                .map(InstrumentAny::OptionContract)
524                .collect())
525        }
526        "OptionSpread" => {
527            let option_spreads = option_spread::decode_option_spread_batch(metadata, record_batch)?;
528            Ok(option_spreads
529                .into_iter()
530                .map(InstrumentAny::OptionSpread)
531                .collect())
532        }
533        "PerpetualContract" => {
534            let perpetual_contracts =
535                perpetual_contract::decode_perpetual_contract_batch(metadata, record_batch)?;
536            Ok(perpetual_contracts
537                .into_iter()
538                .map(InstrumentAny::PerpetualContract)
539                .collect())
540        }
541        "TokenizedAsset" => {
542            let tokenized_assets =
543                tokenized_asset::decode_tokenized_asset_batch(metadata, record_batch)?;
544            Ok(tokenized_assets
545                .into_iter()
546                .map(InstrumentAny::TokenizedAsset)
547                .collect())
548        }
549        _ => Err(EncodingError::ParseError(
550            "class",
551            format!("Unknown instrument type: {type_name}"),
552        )),
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use nautilus_core::UnixNanos;
559    use nautilus_model::{
560        enums::CurrencyType,
561        identifiers::{InstrumentId, Symbol},
562        instruments::{InstrumentAny, currency_pair::CurrencyPair},
563        types::{Currency, Price, Quantity},
564    };
565    use rstest::rstest;
566
567    use super::*;
568
569    #[rstest]
570    fn test_get_schema() {
571        let mut metadata = HashMap::new();
572        metadata.insert("class".to_string(), "CurrencyPair".to_string());
573        let schema = InstrumentAny::get_schema(Some(metadata));
574        assert!(schema.fields().len() >= 20);
575        assert_eq!(schema.field(0).name(), "id");
576    }
577
578    #[rstest]
579    #[case("")]
580    #[case("   ")]
581    #[case("\t\n")]
582    fn test_decode_currency_empty_or_whitespace_errors(#[case] value: &str) {
583        let result = decode_currency(value, "currency", "test.currency", 7);
584        let err = result.expect_err("empty code must surface EncodingError");
585        match err {
586            EncodingError::ParseError(field, msg) => {
587                assert_eq!(field, "currency");
588                assert!(
589                    msg.contains("row 7"),
590                    "message should include row index, found: {msg}",
591                );
592                assert!(
593                    msg.contains("empty currency code"),
594                    "message should describe empty code, found: {msg}",
595                );
596            }
597            other => panic!("unexpected error variant: {other:?}"),
598        }
599        // Ensure the fallback did not register a phantom currency under the empty key.
600        assert!(Currency::try_from_str(value.trim()).is_none());
601    }
602
603    #[rstest]
604    #[case("USD", CurrencyType::Fiat, 2)]
605    #[case("BTC", CurrencyType::Crypto, 8)]
606    #[case("XAU", CurrencyType::CommodityBacked, 2)]
607    fn test_decode_currency_known_code_preserves_metadata(
608        #[case] code: &str,
609        #[case] expected_type: CurrencyType,
610        #[case] expected_precision: u8,
611    ) {
612        let currency = decode_currency(code, "currency", "test.currency", 0).unwrap();
613        assert_eq!(currency.code.as_str(), code);
614        assert_eq!(currency.currency_type, expected_type);
615        assert_eq!(currency.precision, expected_precision);
616    }
617
618    #[rstest]
619    fn test_decode_currency_unknown_code_registers_as_crypto() {
620        let code = "XDECTEST";
621        assert!(
622            Currency::try_from_str(code).is_none(),
623            "test precondition: '{code}' must not be pre-registered",
624        );
625
626        let currency = decode_currency(code, "base_currency", "test.base_currency", 0).unwrap();
627        assert_eq!(currency.code.as_str(), code);
628        assert_eq!(currency.currency_type, CurrencyType::Crypto);
629        assert_eq!(currency.precision, 8);
630        assert_eq!(currency.iso4217, 0);
631
632        let registered = Currency::try_from_str(code).expect("unknown code must be registered");
633        assert_eq!(registered, currency);
634    }
635
636    #[rstest]
637    fn test_encode_decode_round_trip() {
638        use nautilus_model::instruments::Instrument;
639        let instrument_id = InstrumentId::from("EUR/USD.SIM");
640        let currency_pair = CurrencyPair::new(
641            instrument_id,
642            Symbol::from("EUR/USD"),
643            Currency::from("EUR"),
644            Currency::from("USD"),
645            5,
646            0, // size_precision must match size_increment precision (0)
647            Price::new(0.00001, 5),
648            Quantity::new(1.0, 0), // precision 0
649            None,                  // multiplier
650            None,                  // lot_size
651            None,                  // max_quantity
652            None,                  // min_quantity
653            None,                  // max_notional
654            None,                  // min_notional
655            None,                  // max_price
656            None,                  // min_price
657            None,                  // margin_init
658            None,                  // margin_maint
659            None,                  // maker_fee
660            None,                  // taker_fee
661            None,                  // info
662            UnixNanos::default(),
663            UnixNanos::default(),
664        );
665        let instrument = InstrumentAny::CurrencyPair(currency_pair);
666
667        let metadata = instrument.metadata();
668        let record_batch =
669            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
670        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
671
672        assert_eq!(decoded.len(), 1);
673        assert_eq!(Instrument::id(&decoded[0]), Instrument::id(&instrument));
674        assert_eq!(
675            Instrument::raw_symbol(&decoded[0]),
676            Instrument::raw_symbol(&instrument)
677        );
678        assert_eq!(
679            Instrument::asset_class(&decoded[0]),
680            Instrument::asset_class(&instrument)
681        );
682
683        match (&decoded[0], &instrument) {
684            (InstrumentAny::CurrencyPair(decoded_cp), InstrumentAny::CurrencyPair(original_cp)) => {
685                assert_eq!(decoded_cp.id, original_cp.id);
686                assert_eq!(decoded_cp.base_currency, original_cp.base_currency);
687                assert_eq!(decoded_cp.quote_currency, original_cp.quote_currency);
688                assert_eq!(decoded_cp.price_precision, original_cp.price_precision);
689                assert_eq!(decoded_cp.size_precision, original_cp.size_precision);
690            }
691            _ => panic!("Decoded instrument type mismatch"),
692        }
693    }
694
695    #[rstest]
696    fn test_encode_decode_round_trip_equity() {
697        use nautilus_model::instruments::{Instrument, equity::Equity};
698
699        let instrument_id = InstrumentId::from("AAPL.NASDAQ");
700        let equity = Equity::new(
701            instrument_id,
702            Symbol::from("AAPL"),
703            None, // isin
704            Currency::from("USD"),
705            2,
706            Price::new(0.01, 2),
707            None, // lot_size
708            None, // max_quantity
709            None, // min_quantity
710            None, // max_price
711            None, // min_price
712            None, // margin_init
713            None, // margin_maint
714            None, // maker_fee
715            None, // taker_fee
716            None, // info
717            UnixNanos::default(),
718            UnixNanos::default(),
719        );
720        let instrument = InstrumentAny::Equity(equity);
721
722        let metadata = instrument.metadata();
723        let record_batch =
724            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(&instrument)).unwrap();
725        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
726        assert_eq!(decoded.len(), 1);
727        assert_eq!(Instrument::id(&decoded[0]), Instrument::id(&instrument));
728        assert_eq!(
729            Instrument::raw_symbol(&decoded[0]),
730            Instrument::raw_symbol(&instrument)
731        );
732        assert_eq!(
733            Instrument::asset_class(&decoded[0]),
734            Instrument::asset_class(&instrument)
735        );
736
737        match (&decoded[0], &instrument) {
738            (InstrumentAny::Equity(decoded_eq), InstrumentAny::Equity(original_eq)) => {
739                assert_eq!(decoded_eq.id, original_eq.id);
740                assert_eq!(decoded_eq.currency, original_eq.currency);
741                assert_eq!(decoded_eq.price_precision, original_eq.price_precision);
742            }
743            _ => panic!("Decoded instrument type mismatch"),
744        }
745    }
746
747    fn roundtrip_case(instrument: &InstrumentAny) {
748        use nautilus_model::instruments::Instrument;
749
750        let metadata = instrument.metadata();
751        let record_batch =
752            InstrumentAny::encode_batch(&metadata, std::slice::from_ref(instrument)).unwrap();
753        let decoded = decode_instrument_any_batch(&metadata, &record_batch).unwrap();
754
755        assert_eq!(decoded.len(), 1);
756        assert_eq!(Instrument::id(&decoded[0]), Instrument::id(instrument));
757        assert_eq!(
758            Instrument::raw_symbol(&decoded[0]),
759            Instrument::raw_symbol(instrument)
760        );
761        assert_eq!(
762            Instrument::asset_class(&decoded[0]),
763            Instrument::asset_class(instrument)
764        );
765        assert_eq!(
766            Instrument::instrument_class(&decoded[0]),
767            Instrument::instrument_class(instrument)
768        );
769        assert_eq!(
770            Instrument::price_precision(&decoded[0]),
771            Instrument::price_precision(instrument)
772        );
773        assert_eq!(
774            Instrument::size_precision(&decoded[0]),
775            Instrument::size_precision(instrument)
776        );
777        assert_eq!(
778            Instrument::quote_currency(&decoded[0]),
779            Instrument::quote_currency(instrument)
780        );
781        assert_eq!(
782            std::mem::discriminant(&decoded[0]),
783            std::mem::discriminant(instrument),
784            "decoded variant must match encoded variant"
785        );
786    }
787
788    #[rstest]
789    fn test_roundtrip_betting() {
790        use nautilus_model::instruments::stubs::betting;
791        roundtrip_case(&InstrumentAny::Betting(betting()));
792    }
793
794    #[rstest]
795    fn test_roundtrip_binary_option() {
796        use nautilus_model::instruments::stubs::binary_option;
797        roundtrip_case(&InstrumentAny::BinaryOption(binary_option()));
798    }
799
800    #[rstest]
801    fn test_roundtrip_cfd() {
802        use nautilus_model::instruments::stubs::cfd_gold;
803        roundtrip_case(&InstrumentAny::Cfd(cfd_gold()));
804    }
805
806    #[rstest]
807    fn test_roundtrip_commodity() {
808        use nautilus_model::instruments::stubs::commodity_gold;
809        roundtrip_case(&InstrumentAny::Commodity(commodity_gold()));
810    }
811
812    #[rstest]
813    fn test_roundtrip_crypto_future() {
814        use nautilus_model::instruments::stubs::crypto_future_btcusdt;
815        roundtrip_case(&InstrumentAny::CryptoFuture(crypto_future_btcusdt(
816            2,
817            6,
818            Price::from("0.01"),
819            Quantity::from("0.000001"),
820        )));
821    }
822
823    #[rstest]
824    fn test_roundtrip_crypto_option() {
825        use nautilus_model::instruments::stubs::crypto_option_btc_deribit;
826        roundtrip_case(&InstrumentAny::CryptoOption(crypto_option_btc_deribit(
827            3,
828            1,
829            Price::from("0.001"),
830            Quantity::from("0.1"),
831        )));
832    }
833
834    #[rstest]
835    fn test_roundtrip_crypto_perpetual_inverse() {
836        use nautilus_model::instruments::stubs::xbtusd_bitmex;
837        roundtrip_case(&InstrumentAny::CryptoPerpetual(xbtusd_bitmex()));
838    }
839
840    #[rstest]
841    fn test_roundtrip_crypto_perpetual_linear() {
842        use nautilus_model::instruments::stubs::crypto_perpetual_ethusdt;
843        roundtrip_case(&InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt()));
844    }
845
846    #[rstest]
847    fn test_roundtrip_futures_contract() {
848        use nautilus_model::instruments::stubs::futures_contract_es;
849        roundtrip_case(&InstrumentAny::FuturesContract(futures_contract_es(
850            None, None,
851        )));
852    }
853
854    #[rstest]
855    fn test_roundtrip_futures_spread() {
856        use nautilus_model::instruments::stubs::futures_spread_es;
857        roundtrip_case(&InstrumentAny::FuturesSpread(futures_spread_es()));
858    }
859
860    #[rstest]
861    fn test_roundtrip_index_instrument() {
862        use nautilus_model::instruments::stubs::index_instrument_spx;
863        roundtrip_case(&InstrumentAny::IndexInstrument(index_instrument_spx()));
864    }
865
866    #[rstest]
867    fn test_roundtrip_option_contract() {
868        use nautilus_model::instruments::stubs::option_contract_appl;
869        roundtrip_case(&InstrumentAny::OptionContract(option_contract_appl()));
870    }
871
872    #[rstest]
873    fn test_roundtrip_option_spread() {
874        use nautilus_model::instruments::stubs::option_spread;
875        roundtrip_case(&InstrumentAny::OptionSpread(option_spread()));
876    }
877
878    #[rstest]
879    fn test_roundtrip_perpetual_contract() {
880        use nautilus_model::instruments::stubs::perpetual_contract_eurusd;
881        roundtrip_case(&InstrumentAny::PerpetualContract(
882            perpetual_contract_eurusd(),
883        ));
884    }
885
886    #[rstest]
887    fn test_roundtrip_tokenized_asset() {
888        use nautilus_model::instruments::stubs::tokenized_asset_aaplx;
889        roundtrip_case(&InstrumentAny::TokenizedAsset(tokenized_asset_aaplx()));
890    }
891}