1use std::sync::Arc;
32
33use arrow::{
34 array::{
35 BooleanBuilder, Float64Builder, StringBuilder, TimestampNanosecondBuilder, UInt8Builder,
36 },
37 datatypes::Schema,
38 error::ArrowError,
39 record_batch::RecordBatch,
40};
41use nautilus_model::instruments::{Instrument, InstrumentAny};
42use rust_decimal::prelude::ToPrimitive;
43
44use super::{
45 bool_field, float64_field, money_to_f64, price_to_f64, quantity_to_f64, timestamp_field,
46 uint8_field, unix_nanos_to_i64, utf8_field,
47};
48
49#[must_use]
51pub fn instrument_schema() -> Schema {
52 Schema::new(vec![
53 utf8_field("instrument_id", false),
54 utf8_field("symbol", false),
55 utf8_field("venue", false),
56 utf8_field("instrument_type", false),
57 utf8_field("raw_symbol", false),
58 utf8_field("asset_class", false),
59 utf8_field("instrument_class", false),
60 utf8_field("underlying", true),
61 utf8_field("base_currency", true),
62 utf8_field("quote_currency", false),
63 utf8_field("settlement_currency", false),
64 utf8_field("isin", true),
65 utf8_field("option_kind", true),
66 utf8_field("exchange", true),
67 float64_field("strike_price", true),
68 timestamp_field("activation_ns", true),
69 timestamp_field("expiration_ns", true),
70 bool_field("is_inverse", false),
71 bool_field("is_quanto", false),
72 uint8_field("price_precision", false),
73 uint8_field("size_precision", false),
74 float64_field("price_increment", false),
75 float64_field("size_increment", false),
76 float64_field("multiplier", false),
77 float64_field("lot_size", true),
78 float64_field("max_quantity", true),
79 float64_field("min_quantity", true),
80 float64_field("max_notional_amount", true),
81 utf8_field("max_notional_currency", true),
82 float64_field("min_notional_amount", true),
83 utf8_field("min_notional_currency", true),
84 float64_field("max_price", true),
85 float64_field("min_price", true),
86 float64_field("margin_init", false),
87 float64_field("margin_maint", false),
88 float64_field("maker_fee", false),
89 float64_field("taker_fee", false),
90 timestamp_field("ts_event", false),
91 timestamp_field("ts_init", false),
92 ])
93}
94
95fn instrument_type_name(instrument: &InstrumentAny) -> &'static str {
97 match instrument {
98 InstrumentAny::Betting(_) => "BettingInstrument",
99 InstrumentAny::BinaryOption(_) => "BinaryOption",
100 InstrumentAny::Cfd(_) => "Cfd",
101 InstrumentAny::Commodity(_) => "Commodity",
102 InstrumentAny::CryptoFuture(_) => "CryptoFuture",
103 InstrumentAny::CryptoFuturesSpread(_) => "CryptoFuturesSpread",
104 InstrumentAny::CryptoOption(_) => "CryptoOption",
105 InstrumentAny::CryptoOptionSpread(_) => "CryptoOptionSpread",
106 InstrumentAny::CryptoPerpetual(_) => "CryptoPerpetual",
107 InstrumentAny::CurrencyPair(_) => "CurrencyPair",
108 InstrumentAny::Equity(_) => "Equity",
109 InstrumentAny::FuturesContract(_) => "FuturesContract",
110 InstrumentAny::FuturesSpread(_) => "FuturesSpread",
111 InstrumentAny::IndexInstrument(_) => "IndexInstrument",
112 InstrumentAny::OptionContract(_) => "OptionContract",
113 InstrumentAny::OptionSpread(_) => "OptionSpread",
114 InstrumentAny::PerpetualContract(_) => "PerpetualContract",
115 InstrumentAny::TokenizedAsset(_) => "TokenizedAsset",
116 }
117}
118
119pub fn encode_instruments(data: &[InstrumentAny]) -> Result<RecordBatch, ArrowError> {
136 let mut instrument_id = StringBuilder::new();
137 let mut symbol = StringBuilder::new();
138 let mut venue = StringBuilder::new();
139 let mut instrument_type = StringBuilder::new();
140 let mut raw_symbol = StringBuilder::new();
141 let mut asset_class = StringBuilder::new();
142 let mut instrument_class = StringBuilder::new();
143 let mut underlying = StringBuilder::new();
144 let mut base_currency = StringBuilder::new();
145 let mut quote_currency = StringBuilder::new();
146 let mut settlement_currency = StringBuilder::new();
147 let mut isin = StringBuilder::new();
148 let mut option_kind = StringBuilder::new();
149 let mut exchange = StringBuilder::new();
150 let mut strike_price = Float64Builder::with_capacity(data.len());
151 let mut activation_ns = TimestampNanosecondBuilder::with_capacity(data.len());
152 let mut expiration_ns = TimestampNanosecondBuilder::with_capacity(data.len());
153 let mut is_inverse = BooleanBuilder::with_capacity(data.len());
154 let mut is_quanto = BooleanBuilder::with_capacity(data.len());
155 let mut price_precision = UInt8Builder::with_capacity(data.len());
156 let mut size_precision = UInt8Builder::with_capacity(data.len());
157 let mut price_increment = Float64Builder::with_capacity(data.len());
158 let mut size_increment = Float64Builder::with_capacity(data.len());
159 let mut multiplier = Float64Builder::with_capacity(data.len());
160 let mut lot_size = Float64Builder::with_capacity(data.len());
161 let mut max_quantity = Float64Builder::with_capacity(data.len());
162 let mut min_quantity = Float64Builder::with_capacity(data.len());
163 let mut max_notional_amount = Float64Builder::with_capacity(data.len());
164 let mut max_notional_currency = StringBuilder::new();
165 let mut min_notional_amount = Float64Builder::with_capacity(data.len());
166 let mut min_notional_currency = StringBuilder::new();
167 let mut max_price = Float64Builder::with_capacity(data.len());
168 let mut min_price = Float64Builder::with_capacity(data.len());
169 let mut margin_init = Float64Builder::with_capacity(data.len());
170 let mut margin_maint = Float64Builder::with_capacity(data.len());
171 let mut maker_fee = Float64Builder::with_capacity(data.len());
172 let mut taker_fee = Float64Builder::with_capacity(data.len());
173 let mut ts_event = TimestampNanosecondBuilder::with_capacity(data.len());
174 let mut ts_init = TimestampNanosecondBuilder::with_capacity(data.len());
175
176 for instrument in data {
177 instrument_id.append_value(instrument.id().to_string());
178 symbol.append_value(instrument.symbol());
179 venue.append_value(instrument.venue());
180 instrument_type.append_value(instrument_type_name(instrument));
181 raw_symbol.append_value(instrument.raw_symbol());
182 asset_class.append_value(format!("{}", instrument.asset_class()));
183 instrument_class.append_value(format!("{}", instrument.instrument_class()));
184 underlying.append_option(instrument.underlying().map(|v| v.to_string()));
185 base_currency.append_option(instrument.base_currency().map(|v| v.to_string()));
186 quote_currency.append_value(instrument.quote_currency().to_string());
187 settlement_currency.append_value(instrument.settlement_currency().to_string());
188 isin.append_option(instrument.isin().map(|v| v.to_string()));
189 option_kind.append_option(instrument.option_kind().map(|v| format!("{v}")));
190 exchange.append_option(instrument.exchange().map(|v| v.to_string()));
191 strike_price.append_option(instrument.strike_price().map(|v| price_to_f64(&v)));
192 activation_ns.append_option(
193 instrument
194 .activation_ns()
195 .map(|v| unix_nanos_to_i64(v.as_u64())),
196 );
197 expiration_ns.append_option(
198 instrument
199 .expiration_ns()
200 .map(|v| unix_nanos_to_i64(v.as_u64())),
201 );
202 is_inverse.append_value(instrument.is_inverse());
203 is_quanto.append_value(instrument.is_quanto());
204 price_precision.append_value(instrument.price_precision());
205 size_precision.append_value(instrument.size_precision());
206 price_increment.append_value(price_to_f64(&instrument.price_increment()));
207 size_increment.append_value(quantity_to_f64(&instrument.size_increment()));
208 multiplier.append_value(quantity_to_f64(&instrument.multiplier()));
209 lot_size.append_option(instrument.lot_size().map(|v| quantity_to_f64(&v)));
210 max_quantity.append_option(instrument.max_quantity().map(|v| quantity_to_f64(&v)));
211 min_quantity.append_option(instrument.min_quantity().map(|v| quantity_to_f64(&v)));
212 max_notional_amount.append_option(instrument.max_notional().map(|v| money_to_f64(&v)));
213 max_notional_currency
214 .append_option(instrument.max_notional().map(|v| v.currency.to_string()));
215 min_notional_amount.append_option(instrument.min_notional().map(|v| money_to_f64(&v)));
216 min_notional_currency
217 .append_option(instrument.min_notional().map(|v| v.currency.to_string()));
218 max_price.append_option(instrument.max_price().map(|v| price_to_f64(&v)));
219 min_price.append_option(instrument.min_price().map(|v| price_to_f64(&v)));
220 margin_init.append_value(instrument.margin_init().to_f64().unwrap_or(f64::NAN));
221 margin_maint.append_value(instrument.margin_maint().to_f64().unwrap_or(f64::NAN));
222 maker_fee.append_value(instrument.maker_fee().to_f64().unwrap_or(f64::NAN));
223 taker_fee.append_value(instrument.taker_fee().to_f64().unwrap_or(f64::NAN));
224 ts_event.append_value(unix_nanos_to_i64(instrument.ts_event().as_u64()));
225 ts_init.append_value(unix_nanos_to_i64(instrument.ts_init().as_u64()));
226 }
227
228 RecordBatch::try_new(
229 Arc::new(instrument_schema()),
230 vec![
231 Arc::new(instrument_id.finish()),
232 Arc::new(symbol.finish()),
233 Arc::new(venue.finish()),
234 Arc::new(instrument_type.finish()),
235 Arc::new(raw_symbol.finish()),
236 Arc::new(asset_class.finish()),
237 Arc::new(instrument_class.finish()),
238 Arc::new(underlying.finish()),
239 Arc::new(base_currency.finish()),
240 Arc::new(quote_currency.finish()),
241 Arc::new(settlement_currency.finish()),
242 Arc::new(isin.finish()),
243 Arc::new(option_kind.finish()),
244 Arc::new(exchange.finish()),
245 Arc::new(strike_price.finish()),
246 Arc::new(activation_ns.finish()),
247 Arc::new(expiration_ns.finish()),
248 Arc::new(is_inverse.finish()),
249 Arc::new(is_quanto.finish()),
250 Arc::new(price_precision.finish()),
251 Arc::new(size_precision.finish()),
252 Arc::new(price_increment.finish()),
253 Arc::new(size_increment.finish()),
254 Arc::new(multiplier.finish()),
255 Arc::new(lot_size.finish()),
256 Arc::new(max_quantity.finish()),
257 Arc::new(min_quantity.finish()),
258 Arc::new(max_notional_amount.finish()),
259 Arc::new(max_notional_currency.finish()),
260 Arc::new(min_notional_amount.finish()),
261 Arc::new(min_notional_currency.finish()),
262 Arc::new(max_price.finish()),
263 Arc::new(min_price.finish()),
264 Arc::new(margin_init.finish()),
265 Arc::new(margin_maint.finish()),
266 Arc::new(maker_fee.finish()),
267 Arc::new(taker_fee.finish()),
268 Arc::new(ts_event.finish()),
269 Arc::new(ts_init.finish()),
270 ],
271 )
272}
273
274#[cfg(test)]
275mod tests {
276 use arrow::{
277 array::{Array, BooleanArray, Float64Array, StringArray, TimestampNanosecondArray},
278 datatypes::{DataType, TimeUnit},
279 };
280 use nautilus_model::{
281 instruments::{
282 InstrumentAny,
283 stubs::{
284 betting, binary_option, cfd_gold, commodity_gold, crypto_future_btcusdt,
285 crypto_option_btc_deribit, crypto_perpetual_ethusdt, currency_pair_btcusdt,
286 equity_aapl, futures_contract_es, futures_spread_es, index_instrument_spx,
287 option_contract_appl, option_spread, perpetual_contract_eurusd,
288 tokenized_asset_aaplx, xbtusd_bitmex,
289 },
290 },
291 types::{Price, Quantity},
292 };
293 use rstest::rstest;
294
295 use super::*;
296
297 fn spot() -> InstrumentAny {
298 InstrumentAny::CurrencyPair(currency_pair_btcusdt())
299 }
300
301 fn all_variants() -> Vec<(InstrumentAny, &'static str)> {
302 vec![
303 (InstrumentAny::Betting(betting()), "BettingInstrument"),
304 (InstrumentAny::BinaryOption(binary_option()), "BinaryOption"),
305 (InstrumentAny::Cfd(cfd_gold()), "Cfd"),
306 (InstrumentAny::Commodity(commodity_gold()), "Commodity"),
307 (
308 InstrumentAny::CryptoFuture(crypto_future_btcusdt(
309 2,
310 6,
311 Price::from("0.01"),
312 Quantity::from("0.000001"),
313 )),
314 "CryptoFuture",
315 ),
316 (
317 InstrumentAny::CryptoOption(crypto_option_btc_deribit(
318 3,
319 1,
320 Price::from("0.001"),
321 Quantity::from("0.1"),
322 )),
323 "CryptoOption",
324 ),
325 (
326 InstrumentAny::CryptoPerpetual(crypto_perpetual_ethusdt()),
327 "CryptoPerpetual",
328 ),
329 (
330 InstrumentAny::CurrencyPair(currency_pair_btcusdt()),
331 "CurrencyPair",
332 ),
333 (InstrumentAny::Equity(equity_aapl()), "Equity"),
334 (
335 InstrumentAny::FuturesContract(futures_contract_es(None, None)),
336 "FuturesContract",
337 ),
338 (
339 InstrumentAny::FuturesSpread(futures_spread_es()),
340 "FuturesSpread",
341 ),
342 (
343 InstrumentAny::IndexInstrument(index_instrument_spx()),
344 "IndexInstrument",
345 ),
346 (
347 InstrumentAny::OptionContract(option_contract_appl()),
348 "OptionContract",
349 ),
350 (InstrumentAny::OptionSpread(option_spread()), "OptionSpread"),
351 (
352 InstrumentAny::PerpetualContract(perpetual_contract_eurusd()),
353 "PerpetualContract",
354 ),
355 (
356 InstrumentAny::TokenizedAsset(tokenized_asset_aaplx()),
357 "TokenizedAsset",
358 ),
359 ]
360 }
361
362 #[rstest]
363 fn test_encode_instruments_schema() {
364 let batch = encode_instruments(&[]).unwrap();
365 let schema = batch.schema();
366 let fields = schema.fields();
367 assert_eq!(fields.len(), 39);
368 assert_eq!(fields[0].name(), "instrument_id");
369 assert_eq!(fields[0].data_type(), &DataType::Utf8);
370 assert_eq!(fields[14].name(), "strike_price");
371 assert_eq!(fields[14].data_type(), &DataType::Float64);
372 assert_eq!(fields[17].name(), "is_inverse");
373 assert_eq!(fields[17].data_type(), &DataType::Boolean);
374 assert_eq!(fields[19].name(), "price_precision");
375 assert_eq!(fields[19].data_type(), &DataType::UInt8);
376 assert_eq!(fields[33].name(), "margin_init");
377 assert_eq!(fields[33].data_type(), &DataType::Float64);
378 assert_eq!(fields[36].name(), "taker_fee");
379 assert_eq!(fields[37].name(), "ts_event");
380 assert_eq!(
381 fields[37].data_type(),
382 &DataType::Timestamp(TimeUnit::Nanosecond, None)
383 );
384 }
385
386 #[rstest]
387 fn test_encode_instruments_empty() {
388 let batch = encode_instruments(&[]).unwrap();
389 assert_eq!(batch.num_rows(), 0);
390 assert_eq!(batch.schema().fields().len(), 39);
391 }
392
393 #[rstest]
394 fn test_encode_instruments_spot_values() {
395 let instruments = vec![spot()];
396 let batch = encode_instruments(&instruments).unwrap();
397
398 assert_eq!(batch.num_rows(), 1);
399
400 let instrument_type_col = batch
401 .column(3)
402 .as_any()
403 .downcast_ref::<StringArray>()
404 .unwrap();
405 let strike_price_col = batch
406 .column(14)
407 .as_any()
408 .downcast_ref::<Float64Array>()
409 .unwrap();
410 let activation_col = batch
411 .column(15)
412 .as_any()
413 .downcast_ref::<TimestampNanosecondArray>()
414 .unwrap();
415 let expiration_col = batch
416 .column(16)
417 .as_any()
418 .downcast_ref::<TimestampNanosecondArray>()
419 .unwrap();
420 let is_inverse_col = batch
421 .column(17)
422 .as_any()
423 .downcast_ref::<BooleanArray>()
424 .unwrap();
425 let price_increment_col = batch
426 .column(21)
427 .as_any()
428 .downcast_ref::<Float64Array>()
429 .unwrap();
430
431 assert_eq!(instrument_type_col.value(0), "CurrencyPair");
432 assert!(strike_price_col.is_null(0));
433 assert!(activation_col.is_null(0));
434 assert!(expiration_col.is_null(0));
435 assert!(!is_inverse_col.value(0));
436 assert!(price_increment_col.value(0) > 0.0);
437 }
438
439 #[rstest]
440 fn test_encode_instruments_mixed_variants_preserves_per_row_nulls() {
441 let instruments = vec![
442 spot(),
443 InstrumentAny::Equity(equity_aapl()),
444 InstrumentAny::OptionContract(option_contract_appl()),
445 ];
446 let batch = encode_instruments(&instruments).unwrap();
447
448 assert_eq!(batch.num_rows(), 3);
449
450 let instrument_type_col = batch
451 .column(3)
452 .as_any()
453 .downcast_ref::<StringArray>()
454 .unwrap();
455 let strike_price_col = batch
456 .column(14)
457 .as_any()
458 .downcast_ref::<Float64Array>()
459 .unwrap();
460 let expiration_col = batch
461 .column(16)
462 .as_any()
463 .downcast_ref::<TimestampNanosecondArray>()
464 .unwrap();
465 let base_currency_col = batch
466 .column(8)
467 .as_any()
468 .downcast_ref::<StringArray>()
469 .unwrap();
470
471 assert_eq!(instrument_type_col.value(0), "CurrencyPair");
472 assert_eq!(instrument_type_col.value(1), "Equity");
473 assert_eq!(instrument_type_col.value(2), "OptionContract");
474
475 assert!(strike_price_col.is_null(0));
477 assert!(strike_price_col.is_null(1));
478 assert!(!strike_price_col.is_null(2));
479 assert!(expiration_col.is_null(0));
480 assert!(expiration_col.is_null(1));
481 assert!(!expiration_col.is_null(2));
482
483 assert!(!base_currency_col.is_null(0));
485 assert!(base_currency_col.is_null(1));
486 }
487
488 #[rstest]
489 fn test_encode_instruments_shared_schema_across_batches() {
490 let a = encode_instruments(&[spot()]).unwrap();
491 let b = encode_instruments(&[InstrumentAny::Equity(equity_aapl())]).unwrap();
492 assert_eq!(a.schema(), b.schema());
493 }
494
495 #[rstest]
496 fn test_encode_instruments_all_variant_names() {
497 let variants = all_variants();
498 assert_eq!(variants.len(), 16, "all InstrumentAny variants covered");
499
500 let instruments: Vec<InstrumentAny> = variants.iter().map(|(v, _)| v.clone()).collect();
501 let batch = encode_instruments(&instruments).unwrap();
502 let instrument_type_col = batch
503 .column(3)
504 .as_any()
505 .downcast_ref::<StringArray>()
506 .unwrap();
507
508 for (row, (_, expected)) in variants.iter().enumerate() {
509 assert_eq!(instrument_type_col.value(row), *expected);
510 }
511 }
512
513 #[rstest]
514 fn test_encode_instruments_inverse_perpetual() {
515 let instruments = vec![InstrumentAny::CryptoPerpetual(xbtusd_bitmex())];
516 let batch = encode_instruments(&instruments).unwrap();
517
518 let instrument_type_col = batch
519 .column(3)
520 .as_any()
521 .downcast_ref::<StringArray>()
522 .unwrap();
523 let settlement_currency_col = batch
524 .column(10)
525 .as_any()
526 .downcast_ref::<StringArray>()
527 .unwrap();
528 let is_inverse_col = batch
529 .column(17)
530 .as_any()
531 .downcast_ref::<BooleanArray>()
532 .unwrap();
533 let max_notional_amount_col = batch
534 .column(27)
535 .as_any()
536 .downcast_ref::<Float64Array>()
537 .unwrap();
538 let max_notional_currency_col = batch
539 .column(28)
540 .as_any()
541 .downcast_ref::<StringArray>()
542 .unwrap();
543 let min_notional_amount_col = batch
544 .column(29)
545 .as_any()
546 .downcast_ref::<Float64Array>()
547 .unwrap();
548 let min_notional_currency_col = batch
549 .column(30)
550 .as_any()
551 .downcast_ref::<StringArray>()
552 .unwrap();
553
554 assert_eq!(instrument_type_col.value(0), "CryptoPerpetual");
555 assert_eq!(settlement_currency_col.value(0), "BTC");
556 assert!(is_inverse_col.value(0));
557 assert!((max_notional_amount_col.value(0) - 10_000_000.0).abs() < 1e-9);
558 assert_eq!(max_notional_currency_col.value(0), "USD");
559 assert!((min_notional_amount_col.value(0) - 1.0).abs() < 1e-9);
560 assert_eq!(min_notional_currency_col.value(0), "USD");
561
562 let margin_init_col = batch
563 .column(33)
564 .as_any()
565 .downcast_ref::<Float64Array>()
566 .unwrap();
567 let margin_maint_col = batch
568 .column(34)
569 .as_any()
570 .downcast_ref::<Float64Array>()
571 .unwrap();
572 let maker_fee_col = batch
573 .column(35)
574 .as_any()
575 .downcast_ref::<Float64Array>()
576 .unwrap();
577 let taker_fee_col = batch
578 .column(36)
579 .as_any()
580 .downcast_ref::<Float64Array>()
581 .unwrap();
582
583 assert!((margin_init_col.value(0) - 0.01).abs() < 1e-9);
584 assert!((margin_maint_col.value(0) - 0.0035).abs() < 1e-9);
585 assert!((maker_fee_col.value(0) - (-0.00025)).abs() < 1e-9);
586 assert!((taker_fee_col.value(0) - 0.00075).abs() < 1e-9);
587 }
588}