1use std::{collections::HashMap, str::FromStr, sync::Arc};
17
18use arrow::{
19 array::{Decimal128Array, StringArray, StringBuilder, StringViewArray, UInt64Array},
20 datatypes::{DataType, Field, Schema},
21 error::ArrowError,
22 record_batch::RecordBatch,
23};
24#[cfg(test)]
25use nautilus_model::identifiers::InstrumentId;
26use nautilus_model::{data::TradeTick, enums::AggressorSide, identifiers::TradeId};
27
28use super::{
29 DecodeDataFromRecordBatch, EncodingError, KEY_IDENTIFIER, decode_required_decimal_price,
30 decode_required_decimal_quantity, decode_required_timestamp, enum_dictionary_array,
31 enum_dictionary_data_type, extract_column, extract_column_string, fixed_decimal_data_type,
32 identifier_array_from_display, metadata_with_type_name, parse_metadata,
33 required_price_decimal_array, required_quantity_decimal_array, validate_required_column,
34};
35#[cfg(test)]
36use super::{KEY_INSTRUMENT_ID, KEY_PRICE_PRECISION};
37use crate::arrow::{ArrowSchemaProvider, Data, DecodeFromRecordBatch, EncodeToRecordBatch};
38
39impl ArrowSchemaProvider for TradeTick {
40 fn get_schema(metadata: Option<HashMap<String, String>>) -> Schema {
41 let fields = vec![
42 Field::new("price", fixed_decimal_data_type(), false),
43 Field::new("size", fixed_decimal_data_type(), false),
44 Field::new("aggressor_side", enum_dictionary_data_type(), false),
45 Field::new("trade_id", DataType::Utf8, false),
46 Field::new("ts_event", crate::arrow::timestamp_data_type(), false),
47 Field::new("ts_init", crate::arrow::timestamp_data_type(), false),
48 Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
49 ];
50
51 Schema::new_with_metadata(fields, metadata_with_type_name("TradeTick", metadata))
52 }
53}
54
55impl EncodeToRecordBatch for TradeTick {
56 fn encode_batch<T>(
57 metadata: &HashMap<String, String>,
58 data: &[T],
59 ) -> Result<RecordBatch, ArrowError>
60 where
61 T: std::borrow::Borrow<Self>,
62 {
63 let mut trade_id_builder = StringBuilder::new();
64 let mut ts_event_builder = UInt64Array::builder(data.len());
65 let mut ts_init_builder = UInt64Array::builder(data.len());
66
67 for tick in data.iter().map(std::borrow::Borrow::borrow) {
68 trade_id_builder.append_value(tick.trade_id.to_string());
69 ts_event_builder.append_value(tick.ts_event.as_u64());
70 ts_init_builder.append_value(tick.ts_init.as_u64());
71 }
72
73 let price_array = Arc::new(required_price_decimal_array(
74 data.iter()
75 .map(std::borrow::Borrow::borrow)
76 .map(|tick| tick.price.raw()),
77 "price",
78 )?);
79 let size_array = Arc::new(required_quantity_decimal_array(
80 data.iter()
81 .map(std::borrow::Borrow::borrow)
82 .map(|tick| tick.size.raw()),
83 "size",
84 )?);
85 let aggressor_side_array = Arc::new(enum_dictionary_array(
86 data.iter()
87 .map(std::borrow::Borrow::borrow)
88 .map(|tick| tick.aggressor_side),
89 )?);
90 let trade_id_array = Arc::new(trade_id_builder.finish());
91 let ts_event_array = Arc::new(ts_event_builder.finish());
92 let ts_init_array = Arc::new(ts_init_builder.finish());
93
94 crate::arrow::record_batch_with_timestamps(
95 Self::get_schema(Some(metadata.clone())).into(),
96 vec![
97 price_array,
98 size_array,
99 aggressor_side_array,
100 trade_id_array,
101 ts_event_array,
102 ts_init_array,
103 Arc::new(identifier_array_from_display(
104 data.iter()
105 .map(std::borrow::Borrow::borrow)
106 .map(|tick| tick.instrument_id),
107 )),
108 ],
109 )
110 }
111
112 fn metadata(&self) -> HashMap<String, String> {
113 Self::get_metadata(
114 &self.instrument_id,
115 self.price.precision,
116 self.size.precision,
117 )
118 }
119}
120
121impl DecodeFromRecordBatch for TradeTick {
122 fn decode_batch(
123 metadata: &HashMap<String, String>,
124 record_batch: RecordBatch,
125 ) -> Result<Vec<Self>, EncodingError> {
126 let (instrument_id, price_precision, size_precision) = parse_metadata(metadata)?;
127 let record_batch = crate::arrow::record_batch_with_u64_timestamps(&record_batch)?;
128 let record_batch = &record_batch;
129 let cols = record_batch.columns();
130
131 let price_values =
132 extract_column::<Decimal128Array>(cols, "price", 0, fixed_decimal_data_type())?;
133
134 let size_values =
135 extract_column::<Decimal128Array>(cols, "size", 1, fixed_decimal_data_type())?;
136
137 let aggressor_side_values = extract_column_string(cols, "aggressor_side", 2)?;
138 validate_required_column(cols[2].as_ref(), "aggressor_side")?;
139 let ts_event_values = extract_column::<UInt64Array>(cols, "ts_event", 4, DataType::UInt64)?;
140 let ts_init_values = extract_column::<UInt64Array>(cols, "ts_init", 5, DataType::UInt64)?;
141
142 let trade_id_values: Vec<TradeId> = if record_batch
144 .schema()
145 .field_with_name("trade_id")?
146 .data_type()
147 == &DataType::Utf8View
148 {
149 extract_column::<StringViewArray>(cols, "trade_id", 3, DataType::Utf8View)?
150 .iter()
151 .enumerate()
152 .map(|(i, id)| {
153 id.map(TradeId::from).ok_or_else(|| {
154 EncodingError::ParseError("trade_id", format!("NULL value at row {i}"))
155 })
156 })
157 .collect::<Result<Vec<_>, _>>()?
158 } else {
159 extract_column::<StringArray>(cols, "trade_id", 3, DataType::Utf8)?
160 .iter()
161 .enumerate()
162 .map(|(i, id)| {
163 id.map(TradeId::from).ok_or_else(|| {
164 EncodingError::ParseError("trade_id", format!("NULL value at row {i}"))
165 })
166 })
167 .collect::<Result<Vec<_>, _>>()?
168 };
169
170 let result: Result<Vec<Self>, EncodingError> = (0..record_batch.num_rows())
171 .map(|i| {
172 let price =
173 decode_required_decimal_price(price_values, price_precision, "price", i)?;
174 let size =
175 decode_required_decimal_quantity(size_values, size_precision, "size", i)?;
176 let aggressor_side_value = aggressor_side_values.value(i);
177 let aggressor_side =
178 AggressorSide::from_str(aggressor_side_value).map_err(|e| {
179 EncodingError::ParseError(stringify!(AggressorSide), e.to_string())
180 })?;
181 let trade_id = trade_id_values[i];
182 let ts_event = decode_required_timestamp(ts_event_values, "ts_event", i)?;
183 let ts_init = decode_required_timestamp(ts_init_values, "ts_init", i)?;
184
185 Ok(Self {
186 instrument_id,
187 price,
188 size,
189 aggressor_side,
190 trade_id,
191 ts_event,
192 ts_init,
193 })
194 })
195 .collect();
196
197 result
198 }
199}
200
201impl DecodeDataFromRecordBatch for TradeTick {
202 fn decode_data_batch(
203 metadata: &HashMap<String, String>,
204 record_batch: RecordBatch,
205 ) -> Result<Vec<Data>, EncodingError> {
206 let ticks: Vec<Self> = Self::decode_batch(metadata, record_batch)?;
207 Ok(ticks.into_iter().map(Data::from).collect())
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use std::sync::Arc;
214
215 use arrow::array::{Array, Decimal128Array, TimestampNanosecondArray, UInt64Array};
216 use nautilus_model::types::{
217 Price, Quantity, fixed::FIXED_SCALAR, price::PriceRaw, quantity::QuantityRaw,
218 };
219 use rstest::rstest;
220
221 use super::*;
222 use crate::arrow::{KEY_TYPE_NAME, get_raw_price, get_raw_quantity};
223
224 #[rstest]
225 fn test_get_schema() {
226 let instrument_id = InstrumentId::from("AAPL.XNAS");
227 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
228 let schema = TradeTick::get_schema(Some(metadata.clone()));
229
230 let mut expected_fields = Vec::with_capacity(7);
231
232 expected_fields.push(Field::new("price", fixed_decimal_data_type(), false));
233
234 expected_fields.extend(vec![
235 Field::new("size", fixed_decimal_data_type(), false),
236 Field::new("aggressor_side", enum_dictionary_data_type(), false),
237 Field::new("trade_id", DataType::Utf8, false),
238 Field::new("ts_event", crate::arrow::timestamp_data_type(), false),
239 Field::new("ts_init", crate::arrow::timestamp_data_type(), false),
240 Field::new(KEY_IDENTIFIER, DataType::Utf8, true),
241 ]);
242
243 let mut expected_metadata = metadata;
244 expected_metadata.insert(KEY_TYPE_NAME.to_string(), "TradeTick".to_string());
245 let expected_schema = Schema::new_with_metadata(expected_fields, expected_metadata);
246 assert_eq!(schema, expected_schema);
247 }
248
249 #[rstest]
250 fn test_get_schema_map() {
251 let schema_map = TradeTick::get_schema_map();
252 let mut expected_map = HashMap::new();
253
254 let precision_bytes = "Decimal128(38, 16)".to_string();
255 expected_map.insert("price".to_string(), precision_bytes.clone());
256 expected_map.insert("size".to_string(), precision_bytes);
257 expected_map.insert(
258 "aggressor_side".to_string(),
259 "Dictionary(Int8, Utf8)".to_string(),
260 );
261 expected_map.insert("trade_id".to_string(), "Utf8".to_string());
262 expected_map.insert(
263 "ts_event".to_string(),
264 "Timestamp(Nanosecond, Some(\"UTC\"))".to_string(),
265 );
266 expected_map.insert(
267 "ts_init".to_string(),
268 "Timestamp(Nanosecond, Some(\"UTC\"))".to_string(),
269 );
270 expected_map.insert(KEY_IDENTIFIER.to_string(), "Utf8".to_string());
271 assert_eq!(schema_map, expected_map);
272 }
273
274 #[rstest]
275 fn test_encode_trade_tick() {
276 let instrument_id = InstrumentId::from("AAPL.XNAS");
277 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
278
279 let tick1 = TradeTick {
280 instrument_id,
281 price: Price::from("100.10"),
282 size: Quantity::from(1000),
283 aggressor_side: AggressorSide::Buy,
284 trade_id: TradeId::new("1"),
285 ts_event: 1.into(),
286 ts_init: 3.into(),
287 };
288
289 let tick2 = TradeTick {
290 instrument_id,
291 price: Price::from("100.50"),
292 size: Quantity::from(500),
293 aggressor_side: AggressorSide::Sell,
294 trade_id: TradeId::new("2"),
295 ts_event: 2.into(),
296 ts_init: 4.into(),
297 };
298
299 let data = vec![tick1, tick2];
300 let record_batch = TradeTick::encode_batch(&metadata, &data).unwrap();
301 let columns = record_batch.columns();
302
303 let price_values = columns[0]
304 .as_any()
305 .downcast_ref::<Decimal128Array>()
306 .unwrap();
307 assert_eq!(
308 get_raw_price(price_values.value(0)),
309 (100.10 * FIXED_SCALAR) as PriceRaw
310 );
311 assert_eq!(
312 get_raw_price(price_values.value(1)),
313 (100.50 * FIXED_SCALAR) as PriceRaw
314 );
315
316 let size_values = columns[1]
317 .as_any()
318 .downcast_ref::<Decimal128Array>()
319 .unwrap();
320 assert_eq!(
321 get_raw_quantity(size_values.value(0)),
322 (1000.0 * FIXED_SCALAR) as QuantityRaw
323 );
324 assert_eq!(
325 get_raw_quantity(size_values.value(1)),
326 (500.0 * FIXED_SCALAR) as QuantityRaw
327 );
328
329 let aggressor_side_values = extract_column_string(columns, "aggressor_side", 2).unwrap();
330 let trade_id_values = columns[3].as_any().downcast_ref::<StringArray>().unwrap();
331 let ts_event_values = columns[4]
332 .as_any()
333 .downcast_ref::<TimestampNanosecondArray>()
334 .unwrap();
335 let ts_init_values = columns[5]
336 .as_any()
337 .downcast_ref::<TimestampNanosecondArray>()
338 .unwrap();
339
340 assert_eq!(columns.len(), 7);
341 assert_eq!(size_values.len(), 2);
342 assert_eq!(
343 get_raw_quantity(size_values.value(0)),
344 (1000.0 * FIXED_SCALAR) as QuantityRaw
345 );
346 assert_eq!(
347 get_raw_quantity(size_values.value(1)),
348 (500.0 * FIXED_SCALAR) as QuantityRaw
349 );
350 assert_eq!(aggressor_side_values.len(), 2);
351 assert_eq!(aggressor_side_values.value(0), "BUY");
352 assert_eq!(aggressor_side_values.value(1), "SELL");
353 assert_eq!(trade_id_values.len(), 2);
354 assert_eq!(trade_id_values.value(0), "1");
355 assert_eq!(trade_id_values.value(1), "2");
356 assert_eq!(ts_event_values.len(), 2);
357 assert_eq!(ts_event_values.value(0), 1);
358 assert_eq!(ts_event_values.value(1), 2);
359 assert_eq!(ts_init_values.len(), 2);
360 assert_eq!(ts_init_values.value(0), 3);
361 assert_eq!(ts_init_values.value(1), 4);
362 }
363
364 #[rstest]
365 fn test_decode_batch() {
366 let instrument_id = InstrumentId::from("AAPL.XNAS");
367 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
368
369 let raw_price1 = (100.00 * FIXED_SCALAR) as PriceRaw;
370 let raw_price2 = (101.00 * FIXED_SCALAR) as PriceRaw;
371 let price = crate::arrow::test_support::decimal_array_from_bytes(vec![
372 &raw_price1.to_le_bytes(),
373 &raw_price2.to_le_bytes(),
374 ]);
375
376 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
377 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
378 &((900.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
379 ]);
380 let aggressor_side =
381 enum_dictionary_array([AggressorSide::NoAggressor, AggressorSide::Buy]).unwrap();
382 let trade_id = StringArray::from(vec!["1", "2"]);
383 let ts_event = UInt64Array::from(vec![1, 2]);
384 let ts_init = UInt64Array::from(vec![3, 4]);
385
386 let record_batch = crate::arrow::record_batch_with_timestamps(
387 crate::arrow::schema_without_identifier_column(&TradeTick::get_schema(Some(
388 metadata.clone(),
389 )))
390 .into(),
391 vec![
392 Arc::new(price),
393 Arc::new(size),
394 Arc::new(aggressor_side),
395 Arc::new(trade_id),
396 Arc::new(ts_event),
397 Arc::new(ts_init),
398 ],
399 )
400 .unwrap();
401
402 let decoded_data = TradeTick::decode_batch(&metadata, record_batch).unwrap();
403 assert_eq!(decoded_data.len(), 2);
404 assert_eq!(decoded_data[0].price, Price::from_raw(raw_price1, 2));
405 assert_eq!(decoded_data[1].price, Price::from_raw(raw_price2, 2));
406 }
407
408 #[rstest]
409 fn test_decode_batch_null_trade_id_returns_error() {
410 use arrow::datatypes::Field;
411
412 let instrument_id = InstrumentId::from("AAPL.XNAS");
413 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
414
415 let raw_price = (100.00 * FIXED_SCALAR) as PriceRaw;
416 let price =
417 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
418 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
419 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
420 ]);
421 let aggressor_side = enum_dictionary_array([AggressorSide::NoAggressor]).unwrap();
422
423 let trade_id: StringArray = vec![None::<&str>].into();
424 let ts_event = UInt64Array::from(vec![1]);
425 let ts_init = UInt64Array::from(vec![2]);
426
427 let fields = vec![
429 Field::new("price", fixed_decimal_data_type(), false),
430 Field::new("size", fixed_decimal_data_type(), false),
431 Field::new("aggressor_side", enum_dictionary_data_type(), false),
432 Field::new("trade_id", DataType::Utf8, true), Field::new("ts_event", crate::arrow::timestamp_data_type(), false),
434 Field::new("ts_init", crate::arrow::timestamp_data_type(), false),
435 ];
436 let schema = Schema::new_with_metadata(fields, metadata.clone());
437
438 let record_batch = crate::arrow::record_batch_with_timestamps(
439 schema.into(),
440 vec![
441 Arc::new(price),
442 Arc::new(size),
443 Arc::new(aggressor_side),
444 Arc::new(trade_id),
445 Arc::new(ts_event),
446 Arc::new(ts_init),
447 ],
448 )
449 .unwrap();
450
451 let result = TradeTick::decode_batch(&metadata, record_batch);
452 assert!(result.is_err());
453 let err = result.unwrap_err();
454 assert!(
455 err.to_string().contains("NULL value at row 0"),
456 "Expected NULL error, was: {err}"
457 );
458 }
459
460 #[rstest]
461 #[case::null_key(true)]
462 #[case::null_value(false)]
463 fn test_decode_batch_rejects_null_aggressor_side_with_field_and_row(#[case] null_key: bool) {
464 use arrow::{
465 array::{DictionaryArray, Int8Array},
466 datatypes::Int8Type,
467 };
468
469 let instrument_id = InstrumentId::from("AAPL.XNAS");
470 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
471 let raw_price = (100.00 * FIXED_SCALAR) as PriceRaw;
472 let price =
473 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
474 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
475 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
476 ]);
477
478 let aggressor_side = if null_key {
479 DictionaryArray::<Int8Type>::try_new(
480 Int8Array::from(vec![None]),
481 Arc::new(StringArray::from(vec!["BUY"])),
482 )
483 .unwrap()
484 } else {
485 DictionaryArray::<Int8Type>::try_new(
486 Int8Array::from(vec![0]),
487 Arc::new(StringArray::from(vec![None::<&str>])),
488 )
489 .unwrap()
490 };
491
492 let trade_id = StringArray::from(vec!["1"]);
493 let ts_event = UInt64Array::from(vec![1]);
494 let ts_init = UInt64Array::from(vec![2]);
495 let fields = vec![
496 Field::new("price", fixed_decimal_data_type(), false),
497 Field::new("size", fixed_decimal_data_type(), false),
498 Field::new("aggressor_side", enum_dictionary_data_type(), true),
499 Field::new("trade_id", DataType::Utf8, false),
500 Field::new("ts_event", crate::arrow::timestamp_data_type(), false),
501 Field::new("ts_init", crate::arrow::timestamp_data_type(), false),
502 ];
503 let schema = Schema::new_with_metadata(fields, metadata.clone());
504 let record_batch = crate::arrow::record_batch_with_timestamps(
505 Arc::new(schema),
506 vec![
507 Arc::new(price),
508 Arc::new(size),
509 Arc::new(aggressor_side),
510 Arc::new(trade_id),
511 Arc::new(ts_event),
512 Arc::new(ts_init),
513 ],
514 )
515 .unwrap();
516
517 let result = TradeTick::decode_batch(&metadata, record_batch);
518
519 assert!(matches!(
520 result,
521 Err(EncodingError::ParseError("aggressor_side", ref message))
522 if message == "row 0: required value is null"
523 ));
524 }
525
526 #[rstest]
527 fn test_decode_batch_invalid_price_returns_error() {
528 let instrument_id = InstrumentId::from("AAPL.XNAS");
529 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
530
531 let invalid_price: PriceRaw = PriceRaw::MAX - 1000;
532 let price = crate::arrow::test_support::decimal_array_from_bytes(vec![
533 &invalid_price.to_le_bytes(),
534 ]);
535 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
536 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
537 ]);
538 let aggressor_side = enum_dictionary_array([AggressorSide::NoAggressor]).unwrap();
539 let trade_id = StringArray::from(vec!["1"]);
540 let ts_event = UInt64Array::from(vec![1]);
541 let ts_init = UInt64Array::from(vec![2]);
542
543 let record_batch = crate::arrow::record_batch_with_timestamps(
544 crate::arrow::schema_without_identifier_column(&TradeTick::get_schema(Some(
545 metadata.clone(),
546 )))
547 .into(),
548 vec![
549 Arc::new(price),
550 Arc::new(size),
551 Arc::new(aggressor_side),
552 Arc::new(trade_id),
553 Arc::new(ts_event),
554 Arc::new(ts_init),
555 ],
556 )
557 .unwrap();
558
559 let result = TradeTick::decode_batch(&metadata, record_batch);
560 assert!(result.is_err());
561 let err = result.unwrap_err();
562 assert!(
563 err.to_string().contains("price") && err.to_string().contains("row 0"),
564 "Expected price error at row 0, was: {err}"
565 );
566 }
567
568 #[rstest]
569 fn test_decode_batch_invalid_size_returns_error() {
570 use nautilus_model::types::{fixed::FIXED_PRECISION, quantity::QUANTITY_RAW_MAX};
571
572 let instrument_id = InstrumentId::from("AAPL.XNAS");
573 let metadata = TradeTick::get_metadata(&instrument_id, 2, FIXED_PRECISION);
576
577 let raw_price = (100.00 * FIXED_SCALAR) as PriceRaw;
578 let price =
579 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
580
581 let invalid_size = QUANTITY_RAW_MAX + 1;
582 let size =
583 crate::arrow::test_support::decimal_array_from_bytes(vec![&invalid_size.to_le_bytes()]);
584 let aggressor_side = enum_dictionary_array([AggressorSide::NoAggressor]).unwrap();
585 let trade_id = StringArray::from(vec!["1"]);
586 let ts_event = UInt64Array::from(vec![1]);
587 let ts_init = UInt64Array::from(vec![2]);
588
589 let record_batch = crate::arrow::record_batch_with_timestamps(
590 crate::arrow::schema_without_identifier_column(&TradeTick::get_schema(Some(
591 metadata.clone(),
592 )))
593 .into(),
594 vec![
595 Arc::new(price),
596 Arc::new(size),
597 Arc::new(aggressor_side),
598 Arc::new(trade_id),
599 Arc::new(ts_event),
600 Arc::new(ts_init),
601 ],
602 )
603 .unwrap();
604
605 let result = TradeTick::decode_batch(&metadata, record_batch);
606 assert!(result.is_err());
607 let err = result.unwrap_err();
608 assert!(
609 err.to_string().contains("size") && err.to_string().contains("row 0"),
610 "Expected size error at row 0, was: {err}"
611 );
612 }
613
614 #[rstest]
615 fn test_decode_batch_invalid_aggressor_side_returns_error() {
616 let instrument_id = InstrumentId::from("AAPL.XNAS");
617 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
618
619 let raw_price = (100.00 * FIXED_SCALAR) as PriceRaw;
620 let price =
621 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
622 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
623 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
624 ]);
625
626 let aggressor_side = enum_dictionary_array(["INVALID"]).unwrap();
627 let trade_id = StringArray::from(vec!["1"]);
628 let ts_event = UInt64Array::from(vec![1]);
629 let ts_init = UInt64Array::from(vec![2]);
630
631 let record_batch = crate::arrow::record_batch_with_timestamps(
632 crate::arrow::schema_without_identifier_column(&TradeTick::get_schema(Some(
633 metadata.clone(),
634 )))
635 .into(),
636 vec![
637 Arc::new(price),
638 Arc::new(size),
639 Arc::new(aggressor_side),
640 Arc::new(trade_id),
641 Arc::new(ts_event),
642 Arc::new(ts_init),
643 ],
644 )
645 .unwrap();
646
647 let result = TradeTick::decode_batch(&metadata, record_batch);
648 assert!(result.is_err());
649 let err = result.unwrap_err();
650 assert!(
651 err.to_string().contains("AggressorSide"),
652 "Expected AggressorSide error, was: {err}"
653 );
654 }
655
656 #[rstest]
657 fn test_decode_batch_missing_instrument_id_returns_error() {
658 let instrument_id = InstrumentId::from("AAPL.XNAS");
659 let mut metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
660 metadata.remove(KEY_INSTRUMENT_ID);
661
662 let raw_price = (100.00 * FIXED_SCALAR) as PriceRaw;
663 let price =
664 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
665 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
666 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
667 ]);
668 let aggressor_side = enum_dictionary_array([AggressorSide::NoAggressor]).unwrap();
669 let trade_id = StringArray::from(vec!["1"]);
670 let ts_event = UInt64Array::from(vec![1]);
671 let ts_init = UInt64Array::from(vec![2]);
672
673 let record_batch = crate::arrow::record_batch_with_timestamps(
674 crate::arrow::schema_without_identifier_column(&TradeTick::get_schema(Some(
675 metadata.clone(),
676 )))
677 .into(),
678 vec![
679 Arc::new(price),
680 Arc::new(size),
681 Arc::new(aggressor_side),
682 Arc::new(trade_id),
683 Arc::new(ts_event),
684 Arc::new(ts_init),
685 ],
686 )
687 .unwrap();
688
689 let result = TradeTick::decode_batch(&metadata, record_batch);
690 assert!(result.is_err());
691 let err = result.unwrap_err();
692 assert!(
693 err.to_string().contains("instrument_id"),
694 "Expected missing instrument_id error, was: {err}"
695 );
696 }
697
698 #[rstest]
699 fn test_decode_batch_missing_price_precision_returns_error() {
700 let instrument_id = InstrumentId::from("AAPL.XNAS");
701 let mut metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
702 metadata.remove(KEY_PRICE_PRECISION);
703
704 let raw_price = (100.00 * FIXED_SCALAR) as PriceRaw;
705 let price =
706 crate::arrow::test_support::decimal_array_from_bytes(vec![&raw_price.to_le_bytes()]);
707 let size = crate::arrow::test_support::decimal_array_from_bytes(vec![
708 &((1000.0 * FIXED_SCALAR) as QuantityRaw).to_le_bytes(),
709 ]);
710 let aggressor_side = enum_dictionary_array([AggressorSide::NoAggressor]).unwrap();
711 let trade_id = StringArray::from(vec!["1"]);
712 let ts_event = UInt64Array::from(vec![1]);
713 let ts_init = UInt64Array::from(vec![2]);
714
715 let record_batch = crate::arrow::record_batch_with_timestamps(
716 crate::arrow::schema_without_identifier_column(&TradeTick::get_schema(Some(
717 metadata.clone(),
718 )))
719 .into(),
720 vec![
721 Arc::new(price),
722 Arc::new(size),
723 Arc::new(aggressor_side),
724 Arc::new(trade_id),
725 Arc::new(ts_event),
726 Arc::new(ts_init),
727 ],
728 )
729 .unwrap();
730
731 let result = TradeTick::decode_batch(&metadata, record_batch);
732 assert!(result.is_err());
733 let err = result.unwrap_err();
734 assert!(
735 err.to_string().contains("price_precision"),
736 "Expected missing price_precision error, was: {err}"
737 );
738 }
739
740 #[rstest]
741 fn test_encode_decode_round_trip() {
742 let instrument_id = InstrumentId::from("AAPL.XNAS");
743 let metadata = TradeTick::get_metadata(&instrument_id, 2, 0);
744
745 let tick1 = TradeTick {
746 instrument_id,
747 price: Price::from("100.10"),
748 size: Quantity::from(1000),
749 aggressor_side: AggressorSide::Buy,
750 trade_id: TradeId::new("trade-123"),
751 ts_event: 1_000_000_000.into(),
752 ts_init: 1_000_000_001.into(),
753 };
754
755 let tick2 = TradeTick {
756 instrument_id,
757 price: Price::from("100.50"),
758 size: Quantity::from(500),
759 aggressor_side: AggressorSide::Sell,
760 trade_id: TradeId::new("trade-456"),
761 ts_event: 2_000_000_000.into(),
762 ts_init: 2_000_000_001.into(),
763 };
764
765 let original = vec![tick1, tick2];
766 let record_batch = TradeTick::encode_batch(&metadata, &original).unwrap();
767 let decoded = TradeTick::decode_batch(&metadata, record_batch).unwrap();
768
769 assert_eq!(decoded.len(), original.len());
770 for (orig, dec) in original.iter().zip(decoded.iter()) {
771 assert_eq!(dec.instrument_id, orig.instrument_id);
772 assert_eq!(dec.price, orig.price);
773 assert_eq!(dec.size, orig.size);
774 assert_eq!(dec.aggressor_side, orig.aggressor_side);
775 assert_eq!(dec.trade_id, orig.trade_id);
776 assert_eq!(dec.ts_event, orig.ts_event);
777 assert_eq!(dec.ts_init, orig.ts_init);
778 }
779 }
780}