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