1use 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
69pub(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
107pub(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 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, ¤cy_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
518pub 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 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, Price::new(0.00001, 5),
763 Quantity::new(1.0, 0), None, None, None, None, None, None, None, None, None, None, None, None, Some(Ustr::from("FOREX_5DECIMAL")),
777 None, 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, None, None, None, None, None, None, None, None, None, None, None, Some(Ustr::from("FOREX_5DECIMAL")),
837 None, 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, Currency::from("USD"),
869 2,
870 Price::new(0.01, 2),
871 None, None, None, None, None, None, None, None, None, None, None, 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 assert_eq!(decoded_equity.max_quantity, equity.max_quantity);
954 assert_eq!(decoded_equity.min_quantity, equity.min_quantity);
955
956 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 #[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 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}