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