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