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