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